mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
support for user deleting builds, fixed a lot of auth with the vault
This commit is contained in:
@@ -3,6 +3,7 @@ package group.goforward.battlbuilder.configuration;
|
|||||||
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
import group.goforward.battlbuilder.security.JwtAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.authentication.AuthenticationManager;
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
@@ -12,7 +13,12 @@ import org.springframework.security.config.http.SessionCreationPolicy;
|
|||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@@ -28,19 +34,42 @@ public class SecurityConfig {
|
|||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
http
|
http
|
||||||
.csrf(AbstractHttpConfigurer::disable)
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.cors(c -> c.configurationSource(corsConfigurationSource()))
|
||||||
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// public
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||||
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/builds").permitAll()
|
||||||
|
|
||||||
|
// protected
|
||||||
|
.requestMatchers("/api/v1/builds/me/**").authenticated()
|
||||||
|
|
||||||
|
// everything else (adjust later as you lock down)
|
||||||
.anyRequest().permitAll()
|
.anyRequest().permitAll()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
// run JWT before AnonymousAuth sets principal="anonymousUser"
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, AnonymousAuthenticationFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration cfg = new CorsConfiguration();
|
||||||
|
cfg.setAllowedOrigins(List.of("http://localhost:3000"));
|
||||||
|
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||||
|
cfg.setExposedHeaders(List.of("Authorization"));
|
||||||
|
cfg.setAllowCredentials(true);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", cfg);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import group.goforward.battlbuilder.web.dto.BuildSummaryDto;
|
|||||||
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
import group.goforward.battlbuilder.web.dto.UpdateBuildRequest;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -73,4 +74,15 @@ public class BuildV1Controller {
|
|||||||
) {
|
) {
|
||||||
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
|
return ResponseEntity.ok(buildService.updateMyBuild(uuid, req));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a build (authenticated user; must own build).
|
||||||
|
* DELETE /api/v1/builds/me/{uuid}
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/me/{uuid}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteMyBuild(@PathVariable("uuid") UUID uuid) {
|
||||||
|
buildService.deleteMyBuild(uuid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -15,5 +15,7 @@ public interface BuildRepository extends JpaRepository<Build, Integer> {
|
|||||||
// Temporary vault behavior until Build.user exists:
|
// Temporary vault behavior until Build.user exists:
|
||||||
Page<Build> findByDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable);
|
Page<Build> findByDeletedAtIsNullOrderByUpdatedAtDesc(Pageable pageable);
|
||||||
|
|
||||||
|
Page<Build> findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(Integer userId, Pageable pageable);
|
||||||
|
|
||||||
Optional<Build> findByUuidAndDeletedAtIsNull(UUID uuid);
|
Optional<Build> findByUuidAndDeletedAtIsNull(UUID uuid);
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import jakarta.servlet.FilterChain;
|
|||||||
import jakarta.servlet.ServletException;
|
import jakarta.servlet.ServletException;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.context.SecurityContextHolder;
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
@@ -41,8 +42,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already authenticated? don’t redo work
|
// ✅ If already authenticated with a REAL user, skip.
|
||||||
if (SecurityContextHolder.getContext().getAuthentication() != null) {
|
// ✅ If it's anonymous, we should continue and replace it.
|
||||||
|
var existing = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (existing != null
|
||||||
|
&& existing.isAuthenticated()
|
||||||
|
&& !(existing instanceof AnonymousAuthenticationToken)) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -61,19 +66,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
User user = userRepository.findByUuid(userUuid).orElse(null);
|
User user = userRepository.findByUuid(userUuid).orElse(null);
|
||||||
|
|
||||||
if (user == null || !Boolean.TRUE.equals(user.getIsActive())) {
|
if (user == null || !Boolean.TRUE.equals(user.getIsActive())) {
|
||||||
filterChain.doFilter(request, response);
|
filterChain.doFilter(request, response);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep authorities from your details class…
|
|
||||||
CustomUserDetails userDetails = new CustomUserDetails(user);
|
CustomUserDetails userDetails = new CustomUserDetails(user);
|
||||||
|
|
||||||
// …but set principal to UUID string so controllers can reliably resolve "me"
|
|
||||||
UsernamePasswordAuthenticationToken authToken =
|
UsernamePasswordAuthenticationToken authToken =
|
||||||
new UsernamePasswordAuthenticationToken(
|
new UsernamePasswordAuthenticationToken(
|
||||||
user.getUuid().toString(),
|
user.getUuid().toString(), // principal = UUID string
|
||||||
null,
|
null,
|
||||||
userDetails.getAuthorities()
|
userDetails.getAuthorities()
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import io.jsonwebtoken.security.Keys;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.Key;
|
import java.security.Key;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
@@ -27,7 +28,8 @@ public class JwtService {
|
|||||||
@Value("${security.jwt.secret}") String secret,
|
@Value("${security.jwt.secret}") String secret,
|
||||||
@Value("${security.jwt.access-token-days:30}") long accessTokenDays
|
@Value("${security.jwt.access-token-days:30}") long accessTokenDays
|
||||||
) {
|
) {
|
||||||
this.key = Keys.hmacShaKeyFor(secret.getBytes());
|
// Use a stable charset (avoid platform default weirdness)
|
||||||
|
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||||
this.accessTokenDays = accessTokenDays;
|
this.accessTokenDays = accessTokenDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +44,8 @@ public class JwtService {
|
|||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setClaims(claims)
|
.setClaims(claims)
|
||||||
.setSubject(user.getUuid().toString()) // UUID subject
|
// ✅ Canonical: UUID goes in `sub`
|
||||||
|
.setSubject(user.getUuid().toString())
|
||||||
.setIssuedAt(new Date())
|
.setIssuedAt(new Date())
|
||||||
.setExpiration(Date.from(Instant.now().plus(accessTokenDays, ChronoUnit.DAYS)))
|
.setExpiration(Date.from(Instant.now().plus(accessTokenDays, ChronoUnit.DAYS)))
|
||||||
.signWith(key, SignatureAlgorithm.HS256)
|
.signWith(key, SignatureAlgorithm.HS256)
|
||||||
@@ -51,8 +54,21 @@ public class JwtService {
|
|||||||
|
|
||||||
/** Used by JwtAuthenticationFilter */
|
/** Used by JwtAuthenticationFilter */
|
||||||
public UUID extractUserUuid(String token) {
|
public UUID extractUserUuid(String token) {
|
||||||
Claims claims = parseClaims(token);
|
try {
|
||||||
return UUID.fromString(claims.getSubject());
|
Claims claims = parseClaims(token);
|
||||||
|
String sub = claims.getSubject();
|
||||||
|
|
||||||
|
if (sub == null || sub.isBlank()) return null;
|
||||||
|
|
||||||
|
// ✅ Defensive: old tokens may have sub=email — don't 500 the API
|
||||||
|
try {
|
||||||
|
return UUID.fromString(sub);
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (JwtException | IllegalArgumentException ex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isTokenValid(String token) {
|
public boolean isTokenValid(String token) {
|
||||||
|
|||||||
@@ -19,4 +19,6 @@ public interface BuildService {
|
|||||||
BuildDto createMyBuild(UpdateBuildRequest req);
|
BuildDto createMyBuild(UpdateBuildRequest req);
|
||||||
|
|
||||||
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
|
BuildDto updateMyBuild(UUID uuid, UpdateBuildRequest req);
|
||||||
|
|
||||||
|
void deleteMyBuild(UUID uuid);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package group.goforward.battlbuilder.security;
|
||||||
|
|
||||||
|
import group.goforward.battlbuilder.model.User;
|
||||||
|
import group.goforward.battlbuilder.repos.UserRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CurrentUserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public CurrentUserService(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the authenticated User (401 if missing/invalid). */
|
||||||
|
public User requireUser() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
|
// No auth, or anonymous auth => 401
|
||||||
|
if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// In your setup, JwtAuthenticationFilter sets auth name to UUID string
|
||||||
|
String principal = auth.getName();
|
||||||
|
if (principal == null || principal.isBlank() || "anonymousUser".equalsIgnoreCase(principal)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not authenticated");
|
||||||
|
}
|
||||||
|
|
||||||
|
final UUID userUuid;
|
||||||
|
try {
|
||||||
|
userUuid = UUID.fromString(principal);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid auth principal", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return userRepository.findByUuid(userUuid)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "User not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer requireUserId() {
|
||||||
|
return requireUser().getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import group.goforward.battlbuilder.repos.BuildItemRepository;
|
|||||||
import group.goforward.battlbuilder.repos.BuildProfileRepository;
|
import group.goforward.battlbuilder.repos.BuildProfileRepository;
|
||||||
import group.goforward.battlbuilder.repos.BuildRepository;
|
import group.goforward.battlbuilder.repos.BuildRepository;
|
||||||
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
import group.goforward.battlbuilder.repos.ProductOfferRepository;
|
||||||
|
import group.goforward.battlbuilder.security.CurrentUserService;
|
||||||
import group.goforward.battlbuilder.services.BuildService;
|
import group.goforward.battlbuilder.services.BuildService;
|
||||||
import group.goforward.battlbuilder.web.dto.BuildDto;
|
import group.goforward.battlbuilder.web.dto.BuildDto;
|
||||||
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
import group.goforward.battlbuilder.web.dto.BuildFeedCardDto;
|
||||||
@@ -18,6 +19,15 @@ import org.springframework.data.domain.PageRequest;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -30,17 +40,20 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
private final BuildProfileRepository buildProfileRepository;
|
private final BuildProfileRepository buildProfileRepository;
|
||||||
private final BuildItemRepository buildItemRepository;
|
private final BuildItemRepository buildItemRepository;
|
||||||
private final ProductOfferRepository productOfferRepository;
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
private final CurrentUserService currentUserService;
|
||||||
|
|
||||||
public BuildServiceImpl(
|
public BuildServiceImpl(
|
||||||
BuildRepository buildRepository,
|
BuildRepository buildRepository,
|
||||||
BuildProfileRepository buildProfileRepository,
|
BuildProfileRepository buildProfileRepository,
|
||||||
BuildItemRepository buildItemRepository,
|
BuildItemRepository buildItemRepository,
|
||||||
ProductOfferRepository productOfferRepository
|
ProductOfferRepository productOfferRepository,
|
||||||
|
CurrentUserService currentUserService
|
||||||
) {
|
) {
|
||||||
this.buildRepository = buildRepository;
|
this.buildRepository = buildRepository;
|
||||||
this.buildProfileRepository = buildProfileRepository;
|
this.buildProfileRepository = buildProfileRepository;
|
||||||
this.buildItemRepository = buildItemRepository;
|
this.buildItemRepository = buildItemRepository;
|
||||||
this.productOfferRepository = productOfferRepository;
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
this.currentUserService = currentUserService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
@@ -57,7 +70,10 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
|
|
||||||
if (builds.isEmpty()) return List.of();
|
if (builds.isEmpty()) return List.of();
|
||||||
|
|
||||||
List<Integer> buildIds = builds.stream().map(Build::getId).toList();
|
List<Integer> buildIds = builds.stream()
|
||||||
|
.map(Build::getId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.toList();
|
||||||
|
|
||||||
Map<Integer, BuildProfile> profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds)
|
Map<Integer, BuildProfile> profileByBuildId = buildProfileRepository.findByBuildIdIn(buildIds)
|
||||||
.stream()
|
.stream()
|
||||||
@@ -98,10 +114,10 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
@Override
|
@Override
|
||||||
public List<BuildSummaryDto> listMyBuilds(int limit) {
|
public List<BuildSummaryDto> listMyBuilds(int limit) {
|
||||||
int safeLimit = clamp(limit, 1, 200);
|
int safeLimit = clamp(limit, 1, 200);
|
||||||
|
Integer userId = currentUserService.requireUserId();
|
||||||
|
|
||||||
// MVP: ownership not implemented yet -> return all non-deleted builds
|
|
||||||
List<Build> builds = buildRepository
|
List<Build> builds = buildRepository
|
||||||
.findByDeletedAtIsNullOrderByUpdatedAtDesc(PageRequest.of(0, safeLimit))
|
.findByUserIdAndDeletedAtIsNullOrderByUpdatedAtDesc(userId, PageRequest.of(0, safeLimit))
|
||||||
.getContent();
|
.getContent();
|
||||||
|
|
||||||
if (builds.isEmpty()) return List.of();
|
if (builds.isEmpty()) return List.of();
|
||||||
@@ -118,9 +134,16 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
public BuildDto getMyBuild(UUID uuid) {
|
public BuildDto getMyBuild(UUID uuid) {
|
||||||
if (uuid == null) throw new IllegalArgumentException("uuid is required");
|
if (uuid == null) throw new IllegalArgumentException("uuid is required");
|
||||||
|
|
||||||
|
Integer userId = currentUserService.requireUserId();
|
||||||
|
|
||||||
Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid)
|
Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Build not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Build not found"));
|
||||||
|
|
||||||
|
// prevent leaking other users' builds
|
||||||
|
if (!Objects.equals(build.getUserId(), userId)) {
|
||||||
|
throw new IllegalArgumentException("Build not found");
|
||||||
|
}
|
||||||
|
|
||||||
List<BuildItem> items = buildItemRepository.findByBuild_Id(build.getId());
|
List<BuildItem> items = buildItemRepository.findByBuild_Id(build.getId());
|
||||||
return toBuildDto(build, items);
|
return toBuildDto(build, items);
|
||||||
}
|
}
|
||||||
@@ -135,11 +158,14 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
public BuildDto createMyBuild(UpdateBuildRequest req) {
|
public BuildDto createMyBuild(UpdateBuildRequest req) {
|
||||||
if (req == null) throw new IllegalArgumentException("request body is required");
|
if (req == null) throw new IllegalArgumentException("request body is required");
|
||||||
|
|
||||||
|
Integer userId = currentUserService.requireUserId();
|
||||||
|
|
||||||
String title = (req.getTitle() == null || req.getTitle().isBlank())
|
String title = (req.getTitle() == null || req.getTitle().isBlank())
|
||||||
? "Untitled Build"
|
? "Untitled Build"
|
||||||
: req.getTitle().trim();
|
: req.getTitle().trim();
|
||||||
|
|
||||||
Build build = new Build();
|
Build build = new Build();
|
||||||
|
build.setUserId(userId); // ✅ IMPORTANT: satisfies NOT NULL constraint
|
||||||
build.setTitle(title);
|
build.setTitle(title);
|
||||||
build.setDescription(req.getDescription());
|
build.setDescription(req.getDescription());
|
||||||
build.setIsPublic(req.getIsPublic() != null ? req.getIsPublic() : Boolean.FALSE);
|
build.setIsPublic(req.getIsPublic() != null ? req.getIsPublic() : Boolean.FALSE);
|
||||||
@@ -168,9 +194,15 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
if (uuid == null) throw new IllegalArgumentException("uuid is required");
|
if (uuid == null) throw new IllegalArgumentException("uuid is required");
|
||||||
if (req == null) throw new IllegalArgumentException("request body is required");
|
if (req == null) throw new IllegalArgumentException("request body is required");
|
||||||
|
|
||||||
|
Integer userId = currentUserService.requireUserId();
|
||||||
|
|
||||||
Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid)
|
Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Build not found"));
|
.orElseThrow(() -> new IllegalArgumentException("Build not found"));
|
||||||
|
|
||||||
|
if (!Objects.equals(build.getUserId(), userId)) {
|
||||||
|
throw new IllegalArgumentException("Build not found");
|
||||||
|
}
|
||||||
|
|
||||||
if (req.getTitle() != null) build.setTitle(req.getTitle().trim());
|
if (req.getTitle() != null) build.setTitle(req.getTitle().trim());
|
||||||
if (req.getDescription() != null) build.setDescription(req.getDescription());
|
if (req.getDescription() != null) build.setDescription(req.getDescription());
|
||||||
if (req.getIsPublic() != null) build.setIsPublic(req.getIsPublic());
|
if (req.getIsPublic() != null) build.setIsPublic(req.getIsPublic());
|
||||||
@@ -187,12 +219,37 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
return toBuildDto(saved, items);
|
return toBuildDto(saved, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------
|
||||||
|
// Delete My build (Vault edit Delete)
|
||||||
|
// DELETE /api/v1/builds/me/{uuid}
|
||||||
|
// ---------------------------
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteMyBuild(UUID uuid) {
|
||||||
|
Integer userId = currentUserService.requireUserId();
|
||||||
|
|
||||||
|
Build build = buildRepository.findByUuidAndDeletedAtIsNull(uuid)
|
||||||
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Build not found"));
|
||||||
|
|
||||||
|
// Ownership check
|
||||||
|
Integer currentUserId = currentUserService.requireUserId();
|
||||||
|
|
||||||
|
if (!currentUserId.equals(build.getUserId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Not your build");
|
||||||
|
}
|
||||||
|
|
||||||
|
build.setDeletedAt(OffsetDateTime.now(ZoneOffset.UTC));
|
||||||
|
build.setUpdatedAt(OffsetDateTime.now(ZoneOffset.UTC)); // optional
|
||||||
|
buildRepository.save(build);
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// BuildItem helper
|
// BuildItem helper
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
||||||
private List<BuildItem> buildItemsFromRequest(Build build, List<UpdateBuildRequest.Item> incoming) {
|
private List<BuildItem> buildItemsFromRequest(Build build, List<UpdateBuildRequest.Item> incoming) {
|
||||||
List<BuildItem> out = new ArrayList<>();
|
List<BuildItem> out = new ArrayList<>();
|
||||||
|
if (incoming == null || incoming.isEmpty()) return out;
|
||||||
|
|
||||||
for (UpdateBuildRequest.Item it : incoming) {
|
for (UpdateBuildRequest.Item it : incoming) {
|
||||||
if (it == null) continue;
|
if (it == null) continue;
|
||||||
@@ -202,7 +259,7 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
BuildItem bi = new BuildItem();
|
BuildItem bi = new BuildItem();
|
||||||
bi.setBuild(build);
|
bi.setBuild(build);
|
||||||
|
|
||||||
// Product proxy by ID only
|
// Product proxy by ID only (no DB fetch)
|
||||||
var product = new group.goforward.battlbuilder.model.Product();
|
var product = new group.goforward.battlbuilder.model.Product();
|
||||||
product.setId(it.getProductId());
|
product.setId(it.getProductId());
|
||||||
bi.setProduct(product);
|
bi.setProduct(product);
|
||||||
@@ -218,7 +275,7 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
// DTO mapping (Build -> BuildDto)
|
// DTO mapping
|
||||||
// ---------------------------
|
// ---------------------------
|
||||||
|
|
||||||
private BuildDto toBuildDto(Build build, List<BuildItem> items) {
|
private BuildDto toBuildDto(Build build, List<BuildItem> items) {
|
||||||
@@ -243,14 +300,13 @@ public class BuildServiceImpl implements BuildService {
|
|||||||
it.setPosition(bi.getPosition());
|
it.setPosition(bi.getPosition());
|
||||||
it.setQuantity(bi.getQuantity());
|
it.setQuantity(bi.getQuantity());
|
||||||
|
|
||||||
// BuildItemDto.productId is String
|
|
||||||
it.setProductId(
|
it.setProductId(
|
||||||
(bi.getProduct() != null && bi.getProduct().getId() != null)
|
(bi.getProduct() != null && bi.getProduct().getId() != null)
|
||||||
? String.valueOf(bi.getProduct().getId())
|
? String.valueOf(bi.getProduct().getId())
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Optional / safe defaults for now
|
// optional for now
|
||||||
it.setProductName(null);
|
it.setProductName(null);
|
||||||
it.setProductBrand(null);
|
it.setProductBrand(null);
|
||||||
it.setProductImageUrl(null);
|
it.setProductImageUrl(null);
|
||||||
|
|||||||
@@ -40,4 +40,6 @@ public class BuildDto {
|
|||||||
|
|
||||||
public List<BuildItemDto> getItems() { return items; }
|
public List<BuildItemDto> getItems() { return items; }
|
||||||
public void setItems(List<BuildItemDto> items) { this.items = items; }
|
public void setItems(List<BuildItemDto> items) { this.items = items; }
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package group.goforward.battlbuilder.web.dto;
|
package group.goforward.battlbuilder.web.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public class BuildItemDto {
|
public class BuildItemDto {
|
||||||
@@ -41,4 +42,9 @@ public class BuildItemDto {
|
|||||||
|
|
||||||
public String getProductImageUrl() { return productImageUrl; }
|
public String getProductImageUrl() { return productImageUrl; }
|
||||||
public void setProductImageUrl(String productImageUrl) { this.productImageUrl = productImageUrl; }
|
public void setProductImageUrl(String productImageUrl) { this.productImageUrl = productImageUrl; }
|
||||||
}
|
|
||||||
|
private BigDecimal bestPrice;
|
||||||
|
|
||||||
|
public BigDecimal getBestPrice() { return bestPrice; }
|
||||||
|
public void setBestPrice(BigDecimal bestPrice) { this.bestPrice = bestPrice; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package group.goforward.battlbuilder.web.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public record BuildItemSummaryDto(
|
||||||
|
String slot,
|
||||||
|
Integer productId,
|
||||||
|
String productName,
|
||||||
|
BigDecimal bestPrice
|
||||||
|
) {}
|
||||||
Reference in New Issue
Block a user