Compare commits

11 Commits

64 changed files with 2323 additions and 487 deletions

View File

@@ -1,5 +1,5 @@
# Ballistic Backend # Ballistic Builder ( The Armory?) Backend
### Internal Engine for the Builder Ecosystem ### Internal Engine for the Shadow System Armory?
The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder. The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder.

39
action1.yaml Normal file
View File

@@ -0,0 +1,39 @@
# File: .gitea/workflows/build-and-upload.yml
name: Build and Upload Artifact
on:
push:
branches:
- main
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
# Step 1: Check out repository code
- name: Checkout code
uses: actions/checkout@v4
# Step 2: Set up Node.js (example for a JS project; adjust for your stack)
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 20
# Step 3: Install dependencies
- name: Install dependencies
run: npm ci
# Step 4: Build project
- name: Build project
run: npm run build
# Step 5: Upload build output as artifact
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/ # Change to your build output directory
retention-days: 7 # Optional: how long to keep artifact

View File

@@ -0,0 +1,17 @@
# Stage 1: Build the application
FROM openjdk:17-jdk-slim as build
WORKDIR /app
COPY gradlew .
COPY settings.gradle .
COPY build.gradle .
COPY src ./src
# Adjust the build command for Maven: ./mvnw package -DskipTests
RUN ./gradlew bootJar
# Stage 2: Create the final lightweight image
FROM openjdk:17-jre-slim
WORKDIR /app
# Get the built JAR from the build stage
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -0,0 +1,63 @@
version: '3.8'
services:
# --- 1. Spring API Service (Backend) ---
spring-api:
build:
context: ./backend # Path to your Spring project's root folder
dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend
container_name: spring-api
ports:
- "8080:8080" # Map host port 8080 to container port 8080
environment:
# These environment variables link the API to the database service defined below
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase
- SPRING_DATASOURCE_USERNAME=myuser
- SPRING_DATASOURCE_PASSWORD=mypassword
depends_on:
- db
networks:
- app-network
# --- 2. Next.js App Service (Frontend) ---
nextjs-app:
build:
context: ./frontend # Path to your Next.js project's root folder
dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend
container_name: nextjs-app
ports:
- "3000:3000" # Map host port 3000 to container port 3000
environment:
# This variable is crucial: Next.js needs the URL for the Spring API
# Use the Docker internal service name 'spring-api' and its port 8080
- NEXT_PUBLIC_API_URL=http://spring-api:8080
# For local testing, you might need the host IP for Next.js to call back
# - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080
depends_on:
- spring-api
networks:
- app-network
# --- 3. PostgreSQL Database Service (Example Dependency) ---
db:
image: postgres:15-alpine # Lightweight and stable PostgreSQL image
container_name: postgres-db
environment:
- POSTGRES_DB=mydatabase
- POSTGRES_USER=myuser
- POSTGRES_PASSWORD=mypassword
volumes:
- postgres_data:/var/lib/postgresql/data # Persist the database data
ports:
- "5432:5432" # Optional: Map DB port for external access (e.g., DBeaver)
networks:
- app-network
# --- Docker Volume for Persistent Data ---
volumes:
postgres_data:
# --- Docker Network for Inter-Container Communication ---
networks:
app-network:
driver: bridge

View File

@@ -0,0 +1,22 @@
# Stage 1: Build the static assets
FROM node:20-alpine as builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
# Run the Next.js build command
RUN npm run build
# Stage 2: Run the production application (Next.js server)
FROM node:20-alpine
WORKDIR /app
# Copy only the necessary files for running the app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/public ./public
# Set environment variables
ENV NODE_ENV production
EXPOSE 3000
# Run the Next.js production server
CMD ["npm", "start"]

64
pom.xml
View File

@@ -1,22 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" <project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion> <modelVersion>4.0.0</modelVersion>
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.3</version> <version>3.4.3</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/>
</parent> </parent>
<groupId>group.goforward</groupId> <groupId>group.goforward</groupId>
<artifactId>ballistic</artifactId> <artifactId>ballistic</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>ballistic</name> <name>ballistic</name>
<description>Ballistic Builder API</description> <description>Ballistic Builder API</description>
<url/> <url/>
<licenses> <licenses>
<license/> <license/>
</licenses> </licenses>
<developers> <developers>
<developer> <developer>
<name>Don Strawsburg</name> <name>Don Strawsburg</name>
@@ -29,68 +35,101 @@
<organization>Forward Group, LLC</organization> <organization>Forward Group, LLC</organization>
</developer> </developer>
</developers> </developers>
<scm> <scm>
<connection/> <connection></connection>
<developerConnection/> <developerConnection>scm:git:https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git</developerConnection>
<tag/> <tag/>
<url/> <url/>
</scm> </scm>
<properties> <properties>
<java.version>17</java.version> <java.version>17</java.version>
<maven.compiler.source>${java.version}</maven.compiler.source> <maven.compiler.source>${java.version}</maven.compiler.source>
<maven.compiler.target>${java.version}</maven.compiler.target> <maven.compiler.target>${java.version}</maven.compiler.target>
</properties> </properties>
<dependencies> <dependencies>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<!--
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId> <artifactId>spring-boot-starter-data-rest</artifactId>
</dependency> </dependency>
-->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId> <artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope> <scope>runtime</scope>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springdoc</groupId> <groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.5</version> <version>2.8.5</version>
</dependency> </dependency>
<!--<dependency>
<groupId>org.springframework.boot</groupId> <!-- Jakarta persistence API (optional, JPA starter already brings it transitively) -->
<artifactId>spring-boot-docker-compose</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>-->
<!-- Jakarta / validation API is pulled by starters; explicit jakarta persistence if needed -->
<dependency> <dependency>
<groupId>jakarta.persistence</groupId> <groupId>jakarta.persistence</groupId>
<artifactId>jakarta.persistence-api</artifactId> <artifactId>jakarta.persistence-api</artifactId>
<version>3.1.0</version> <version>3.1.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.postgresql</groupId> <groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId> <artifactId>postgresql</artifactId>
<version>42.7.7</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.apache.commons</groupId> <groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId> <artifactId>commons-csv</artifactId>
<version>1.11.0</version> <version>1.11.0</version>
</dependency> </dependency>
<!-- Spring Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JSON Web Tokens (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
</dependencies> </dependencies>
<build> <build>
@@ -104,6 +143,7 @@
<target>${maven.compiler.target}</target> <target>${maven.compiler.target}</target>
</configuration> </configuration>
</plugin> </plugin>
<plugin> <plugin>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId> <artifactId>spring-boot-maven-plugin</artifactId>
@@ -111,4 +151,4 @@
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@@ -2,6 +2,9 @@ package group.goforward.ballistic;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* @param <T>
*/
public class ApiResponse<T> { public class ApiResponse<T> {
private static final String API_SUCCESS = "success"; private static final String API_SUCCESS = "success";

View File

@@ -3,13 +3,11 @@ package group.goforward.ballistic;
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.context.annotation.ComponentScan; import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication @SpringBootApplication
@ComponentScan("group.goforward.ballistic.controllers") @EnableCaching
@ComponentScan("group.goforward.ballistic.repos")
@ComponentScan("group.goforward.ballistic.services")
@EntityScan(basePackages = "group.goforward.ballistic.model") @EntityScan(basePackages = "group.goforward.ballistic.model")
@EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos") @EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos")
public class BallisticApplication { public class BallisticApplication {

View File

@@ -0,0 +1,16 @@
package group.goforward.ballistic.configuration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// Simple in-memory cache for dev/local
return new ConcurrentMapCacheManager("gunbuilderProducts");
}
}

View File

@@ -0,0 +1,16 @@
package group.goforward.ballistic.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
// @Configuration
// public class PasswordConfig {
// @Bean
// public PasswordEncoder passwordEncoder() {
// // BCrypt default password
// return new BCryptPasswordEncoder();
// }
// }

View File

@@ -0,0 +1,59 @@
package group.goforward.ballistic.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
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.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm ->
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
// Auth endpoints always open
.requestMatchers("/api/auth/**").permitAll()
// Swagger / docs
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// Health
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
// Public product endpoints
.requestMatchers("/api/products/gunbuilder/**").permitAll()
// Everything else (for now) also open we can tighten later
.anyRequest().permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
// BCrypt is a solid default for user passwords
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration
) throws Exception {
return configuration.getAuthenticationManager();
}
}

View File

@@ -0,0 +1,102 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
import group.goforward.ballistic.security.JwtService;
import group.goforward.ballistic.web.dto.auth.AuthResponse;
import group.goforward.ballistic.web.dto.auth.LoginRequest;
import group.goforward.ballistic.web.dto.auth.RegisterRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
@CrossOrigin
public class AuthController {
private final UserRepository users;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
public AuthController(
UserRepository users,
PasswordEncoder passwordEncoder,
JwtService jwtService
) {
this.users = users;
this.passwordEncoder = passwordEncoder;
this.jwtService = jwtService;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
String email = request.getEmail().trim().toLowerCase();
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body("Email is already registered");
}
User user = new User();
// Let DB generate id
user.setUuid(UUID.randomUUID());
user.setEmail(email);
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
user.setDisplayName(request.getDisplayName());
user.setRole("USER");
user.setIsActive(true);
user.setCreatedAt(OffsetDateTime.now());
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
String token = jwtService.generateToken(user);
AuthResponse response = new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
String email = request.getEmail().trim().toLowerCase();
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElse(null);
if (user == null || !user.getIsActive()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
}
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
}
user.setLastLoginAt(OffsetDateTime.now());
user.incrementLoginCount();
user.setUpdatedAt(OffsetDateTime.now());
users.save(user);
String token = jwtService.generateToken(user);
AuthResponse response = new AuthResponse(
token,
user.getEmail(),
user.getDisplayName(),
user.getRole()
);
return ResponseEntity.ok(response);
}
}

View File

@@ -0,0 +1,34 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/categories")
@CrossOrigin // you can tighten origins later
public class CategoryController {
private final PartCategoryRepository partCategories;
public CategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> list() {
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(pc -> new PartCategoryDto(
pc.getId(),
pc.getSlug(),
pc.getName(),
pc.getDescription(),
pc.getGroupName(),
pc.getSortOrder()
))
.toList();
}
}

View File

@@ -0,0 +1,17 @@
# Stage 1: Build the application
FROM openjdk:17-jdk-slim as build
WORKDIR /app
COPY gradlew .
COPY settings.gradle .
COPY build.gradle .
COPY src ./src
# Adjust the build command for Maven: ./mvnw package -DskipTests
RUN ./gradlew bootJar
# Stage 2: Create the final lightweight image
FROM openjdk:17-jre-slim
WORKDIR /app
# Get the built JAR from the build stage
COPY --from=build /app/build/libs/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -7,6 +7,7 @@ import group.goforward.ballistic.web.dto.ProductOfferDto;
import group.goforward.ballistic.repos.ProductRepository; import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.web.dto.ProductSummaryDto; import group.goforward.ballistic.web.dto.ProductSummaryDto;
import group.goforward.ballistic.web.mapper.ProductMapper; import group.goforward.ballistic.web.mapper.ProductMapper;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal; import java.math.BigDecimal;
@@ -30,35 +31,54 @@ public class ProductController {
} }
@GetMapping("/gunbuilder") @GetMapping("/gunbuilder")
@Cacheable(
value = "gunbuilderProducts",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
)
public List<ProductSummaryDto> getGunbuilderProducts( public List<ProductSummaryDto> getGunbuilderProducts(
@RequestParam(defaultValue = "AR-15") String platform, @RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles @RequestParam(required = false, name = "partRoles") List<String> partRoles
) { ) {
// 1) Load products long started = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: start, platform=" + platform +
", partRoles=" + (partRoles == null ? "null" : partRoles));
// 1) Load products (with brand pre-fetched)
long tProductsStart = System.currentTimeMillis();
List<Product> products; List<Product> products;
if (partRoles == null || partRoles.isEmpty()) { if (partRoles == null || partRoles.isEmpty()) {
products = productRepository.findByPlatform(platform); products = productRepository.findByPlatformWithBrand(platform);
} else { } else {
products = productRepository.findByPlatformAndPartRoleIn(platform, partRoles); products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
} }
long tProductsEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded products: " +
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
if (products.isEmpty()) { if (products.isEmpty()) {
long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: 0 products in " + took + " ms");
return List.of(); return List.of();
} }
// 2) Load offers for these product IDs (Integer IDs) // 2) Load offers for these product IDs
long tOffersStart = System.currentTimeMillis();
List<Integer> productIds = products.stream() List<Integer> productIds = products.stream()
.map(Product::getId) .map(Product::getId)
.toList(); .toList();
List<ProductOffer> allOffers = List<ProductOffer> allOffers =
productOfferRepository.findByProductIdIn(productIds); productOfferRepository.findByProductIdIn(productIds);
long tOffersEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded offers: " +
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream() Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
.collect(Collectors.groupingBy(o -> o.getProduct().getId())); .collect(Collectors.groupingBy(o -> o.getProduct().getId()));
// 3) Map to DTOs with price and buyUrl // 3) Map to DTOs with price and buyUrl
return products.stream() long tMapStart = System.currentTimeMillis();
List<ProductSummaryDto> result = products.stream()
.map(p -> { .map(p -> {
List<ProductOffer> offersForProduct = List<ProductOffer> offersForProduct =
offersByProductId.getOrDefault(p.getId(), Collections.emptyList()); offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
@@ -71,25 +91,36 @@ public class ProductController {
return ProductMapper.toSummary(p, price, buyUrl); return ProductMapper.toSummary(p, price, buyUrl);
}) })
.toList(); .toList();
long tMapEnd = System.currentTimeMillis();
long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: mapping to DTOs took " +
(tMapEnd - tMapStart) + " ms");
System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" +
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
"map=" + (tMapEnd - tMapStart) + " ms)");
return result;
} }
@GetMapping("/{id}/offers") @GetMapping("/{id}/offers")
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) { public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
List<ProductOffer> offers = productOfferRepository.findByProductId(productId); List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
return offers.stream() return offers.stream()
.map(offer -> { .map(offer -> {
ProductOfferDto dto = new ProductOfferDto(); ProductOfferDto dto = new ProductOfferDto();
dto.setId(offer.getId().toString()); dto.setId(offer.getId().toString());
dto.setMerchantName(offer.getMerchant().getName()); dto.setMerchantName(offer.getMerchant().getName());
dto.setPrice(offer.getEffectivePrice()); dto.setPrice(offer.getEffectivePrice());
dto.setOriginalPrice(offer.getOriginalPrice()); dto.setOriginalPrice(offer.getOriginalPrice());
dto.setInStock(Boolean.TRUE.equals(offer.getInStock())); dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
dto.setBuyUrl(offer.getBuyUrl()); dto.setBuyUrl(offer.getBuyUrl());
dto.setLastUpdated(offer.getLastSeenAt()); dto.setLastUpdated(offer.getLastSeenAt());
return dto; return dto;
}) })
.toList(); .toList();
} }
private ProductOffer pickBestOffer(List<ProductOffer> offers) { private ProductOffer pickBestOffer(List<ProductOffer> offers) {

View File

@@ -1,7 +1,7 @@
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.Psa; import group.goforward.ballistic.model.Psa;
import group.goforward.ballistic.services.PsaService; import group.goforward.ballistic.services.impl.PsaServiceImpl;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -14,10 +14,10 @@ import java.util.UUID;
@RequestMapping("/api/psa") @RequestMapping("/api/psa")
public class PsaController { public class PsaController {
private final PsaService psaService; private final PsaServiceImpl psaService;
@Autowired @Autowired
public PsaController(PsaService psaService) { public PsaController(PsaServiceImpl psaService) {
this.psaService = psaService; this.psaService = psaService;
} }

View File

@@ -0,0 +1,50 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
import group.goforward.ballistic.services.UsersService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping()
public class UserController {
@Autowired
private UserRepository repo;
@Autowired
private UsersService usersService;
@GetMapping("/api/getAllUsers")
public ResponseEntity<List<User>> getAllUsers() {
List<User> data = repo.findAll();
return ResponseEntity.ok(data);
}
@GetMapping("/api/getAllUsersById/{id}")
public ResponseEntity<User> getAllStatesById(@PathVariable Integer id) {
return repo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/api/addUser")
public ResponseEntity<User> createUser(@RequestBody User item) {
User created = usersService.save(item);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@DeleteMapping("/api/deleteUser/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return usersService.findById(id)
.map(item -> {
usersService.deleteById(id);
return ResponseEntity.noContent().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -0,0 +1,40 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/categories")
@CrossOrigin
public class AdminCategoryController {
private final PartCategoryRepository partCategories;
public AdminCategoryController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> listCategories() {
return partCategories
.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(this::toDto)
.toList();
}
private PartCategoryDto toDto(PartCategory entity) {
return new PartCategoryDto(
entity.getId(),
entity.getSlug(),
entity.getName(),
entity.getDescription(),
entity.getGroupName(),
entity.getSortOrder()
);
}
}

View File

@@ -0,0 +1,117 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.CategoryMapping;
import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.CategoryMappingRepository;
import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.MerchantCategoryMappingDto;
import group.goforward.ballistic.web.dto.admin.SimpleMerchantDto;
import group.goforward.ballistic.web.dto.admin.UpdateMerchantCategoryMappingRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/admin/category-mappings")
@CrossOrigin // you can tighten origins later
public class AdminCategoryMappingController {
private final CategoryMappingRepository categoryMappingRepository;
private final MerchantRepository merchantRepository;
private final PartCategoryRepository partCategoryRepository;
public AdminCategoryMappingController(
CategoryMappingRepository categoryMappingRepository,
MerchantRepository merchantRepository,
PartCategoryRepository partCategoryRepository
) {
this.categoryMappingRepository = categoryMappingRepository;
this.merchantRepository = merchantRepository;
this.partCategoryRepository = partCategoryRepository;
}
/**
* Merchants that have at least one category_mappings row.
* Used for the "All Merchants" dropdown in the UI.
*/
@GetMapping("/merchants")
public List<SimpleMerchantDto> listMerchantsWithMappings() {
List<Merchant> merchants = categoryMappingRepository.findDistinctMerchantsWithMappings();
return merchants.stream()
.map(m -> new SimpleMerchantDto(m.getId(), m.getName()))
.toList();
}
/**
* List mappings for a specific merchant, or all mappings if no merchantId is provided.
* GET /api/admin/category-mappings?merchantId=1
*/
@GetMapping
public List<MerchantCategoryMappingDto> listByMerchant(
@RequestParam(name = "merchantId", required = false) Integer merchantId
) {
List<CategoryMapping> mappings;
if (merchantId != null) {
mappings = categoryMappingRepository.findByMerchantIdOrderByRawCategoryPathAsc(merchantId);
} else {
// fall back to all mappings; you can add a more specific repository method later if desired
mappings = categoryMappingRepository.findAll();
}
return mappings.stream()
.map(cm -> new MerchantCategoryMappingDto(
cm.getId(),
cm.getMerchant().getId(),
cm.getMerchant().getName(),
cm.getRawCategoryPath(),
cm.getPartCategory() != null ? cm.getPartCategory().getId() : null,
cm.getPartCategory() != null ? cm.getPartCategory().getName() : null
))
.toList();
}
/**
* Update a single mapping's part_category.
* POST /api/admin/category-mappings/{id}
* Body: { "partCategoryId": 24 }
*/
@PostMapping("/{id}")
public MerchantCategoryMappingDto updateMapping(
@PathVariable Integer id,
@RequestBody UpdateMerchantCategoryMappingRequest request
) {
CategoryMapping mapping = categoryMappingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
PartCategory partCategory = null;
if (request.partCategoryId() != null) {
partCategory = partCategoryRepository.findById(request.partCategoryId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Part category not found"));
}
mapping.setPartCategory(partCategory);
mapping = categoryMappingRepository.save(mapping);
return new MerchantCategoryMappingDto(
mapping.getId(),
mapping.getMerchant().getId(),
mapping.getMerchant().getName(),
mapping.getRawCategoryPath(),
mapping.getPartCategory() != null ? mapping.getPartCategory().getId() : null,
mapping.getPartCategory() != null ? mapping.getPartCategory().getName() : null
);
}
@PutMapping("/{id}")
public MerchantCategoryMappingDto updateMappingPut(
@PathVariable Integer id,
@RequestBody UpdateMerchantCategoryMappingRequest request
) {
// just delegate so POST & PUT behave the same
return updateMapping(id, request);
}
}

View File

@@ -0,0 +1,123 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.model.PartRoleMapping;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.repos.PartRoleMappingRepository;
import group.goforward.ballistic.web.dto.admin.AdminPartRoleMappingDto;
import group.goforward.ballistic.web.dto.admin.CreatePartRoleMappingRequest;
import group.goforward.ballistic.web.dto.admin.UpdatePartRoleMappingRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@RestController
@RequestMapping("/api/admin/part-role-mappings")
@CrossOrigin
public class AdminPartRoleMappingController {
private final PartRoleMappingRepository partRoleMappingRepository;
private final PartCategoryRepository partCategoryRepository;
public AdminPartRoleMappingController(
PartRoleMappingRepository partRoleMappingRepository,
PartCategoryRepository partCategoryRepository
) {
this.partRoleMappingRepository = partRoleMappingRepository;
this.partCategoryRepository = partCategoryRepository;
}
// GET /api/admin/part-role-mappings?platform=AR-15
@GetMapping
public List<AdminPartRoleMappingDto> list(
@RequestParam(name = "platform", required = false) String platform
) {
List<PartRoleMapping> mappings;
if (platform != null && !platform.isBlank()) {
mappings = partRoleMappingRepository.findByPlatformOrderByPartRoleAsc(platform);
} else {
mappings = partRoleMappingRepository.findAll();
}
return mappings.stream()
.map(this::toDto)
.toList();
}
// POST /api/admin/part-role-mappings
@PostMapping
public AdminPartRoleMappingDto create(
@RequestBody CreatePartRoleMappingRequest request
) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"PartCategory not found for slug: " + request.categorySlug()
));
PartRoleMapping mapping = new PartRoleMapping();
mapping.setPlatform(request.platform());
mapping.setPartRole(request.partRole());
mapping.setPartCategory(category);
mapping.setNotes(request.notes());
mapping = partRoleMappingRepository.save(mapping);
return toDto(mapping);
}
// PUT /api/admin/part-role-mappings/{id}
@PutMapping("/{id}")
public AdminPartRoleMappingDto update(
@PathVariable Integer id,
@RequestBody UpdatePartRoleMappingRequest request
) {
PartRoleMapping mapping = partRoleMappingRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found"));
if (request.platform() != null) {
mapping.setPlatform(request.platform());
}
if (request.partRole() != null) {
mapping.setPartRole(request.partRole());
}
if (request.categorySlug() != null) {
PartCategory category = partCategoryRepository.findBySlug(request.categorySlug())
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"PartCategory not found for slug: " + request.categorySlug()
));
mapping.setPartCategory(category);
}
if (request.notes() != null) {
mapping.setNotes(request.notes());
}
mapping = partRoleMappingRepository.save(mapping);
return toDto(mapping);
}
// DELETE /api/admin/part-role-mappings/{id}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Integer id) {
if (!partRoleMappingRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Mapping not found");
}
partRoleMappingRepository.deleteById(id);
}
private AdminPartRoleMappingDto toDto(PartRoleMapping mapping) {
PartCategory cat = mapping.getPartCategory();
return new AdminPartRoleMappingDto(
mapping.getId(),
mapping.getPlatform(),
mapping.getPartRole(),
cat != null ? cat.getSlug() : null,
cat != null ? cat.getGroupName() : null,
mapping.getNotes()
);
}
}

View File

@@ -0,0 +1,35 @@
package group.goforward.ballistic.controllers.admin;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.PartCategoryRepository;
import group.goforward.ballistic.web.dto.admin.PartCategoryDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/admin/part-categories")
@CrossOrigin // keep it loose for now, you can tighten origins later
public class PartCategoryAdminController {
private final PartCategoryRepository partCategories;
public PartCategoryAdminController(PartCategoryRepository partCategories) {
this.partCategories = partCategories;
}
@GetMapping
public List<PartCategoryDto> list() {
return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
.stream()
.map(pc -> new PartCategoryDto(
pc.getId(),
pc.getSlug(),
pc.getName(),
pc.getDescription(),
pc.getGroupName(),
pc.getSortOrder()
))
.toList();
}
}

View File

@@ -5,23 +5,32 @@ import jakarta.persistence.*;
@Entity @Entity
@Table(name = "affiliate_category_map") @Table(name = "affiliate_category_map")
public class AffiliateCategoryMap { public class AffiliateCategoryMap {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Integer id; private Integer id;
@Column(name = "feedname", nullable = false, length = 100) // e.g. "PART_ROLE", "RAW_CATEGORY", etc.
private String feedname; @Column(name = "source_type", nullable = false)
private String sourceType;
@Column(name = "affiliatecategory", nullable = false) // the value were mapping from (e.g. "suppressor", "TRIGGER")
private String affiliatecategory; @Column(name = "source_value", nullable = false)
private String sourceValue;
@Column(name = "buildercategoryid", nullable = false) // optional platform ("AR-15", "PRECISION", etc.)
private Integer buildercategoryid; @Column(name = "platform")
private String platform;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "part_category_id", nullable = false)
private PartCategory partCategory;
@Column(name = "notes") @Column(name = "notes")
private String notes; private String notes;
// --- getters / setters ---
public Integer getId() { public Integer getId() {
return id; return id;
} }
@@ -30,28 +39,36 @@ public class AffiliateCategoryMap {
this.id = id; this.id = id;
} }
public String getFeedname() { public String getSourceType() {
return feedname; return sourceType;
} }
public void setFeedname(String feedname) { public void setSourceType(String sourceType) {
this.feedname = feedname; this.sourceType = sourceType;
} }
public String getAffiliatecategory() { public String getSourceValue() {
return affiliatecategory; return sourceValue;
} }
public void setAffiliatecategory(String affiliatecategory) { public void setSourceValue(String sourceValue) {
this.affiliatecategory = affiliatecategory; this.sourceValue = sourceValue;
} }
public Integer getBuildercategoryid() { public String getPlatform() {
return buildercategoryid; return platform;
} }
public void setBuildercategoryid(Integer buildercategoryid) { public void setPlatform(String platform) {
this.buildercategoryid = buildercategoryid; this.platform = platform;
}
public PartCategory getPartCategory() {
return partCategory;
}
public void setPartCategory(PartCategory partCategory) {
this.partCategory = partCategory;
} }
public String getNotes() { public String getNotes() {
@@ -61,5 +78,4 @@ public class AffiliateCategoryMap {
public void setNotes(String notes) { public void setNotes(String notes) {
this.notes = notes; this.notes = notes;
} }
} }

View File

@@ -0,0 +1,98 @@
// src/main/java/group/goforward/ballistic/model/CategoryMapping.java
package group.goforward.ballistic.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "category_mappings")
public class CategoryMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Integer id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant;
@Column(name = "raw_category_path", nullable = false)
private String rawCategoryPath;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "part_category_id")
private PartCategory partCategory;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt = OffsetDateTime.now();
@PrePersist
public void onCreate() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void onUpdate() {
this.updatedAt = OffsetDateTime.now();
}
// --- getters & setters ---
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public Merchant getMerchant() {
return merchant;
}
public void setMerchant(Merchant merchant) {
this.merchant = merchant;
}
public String getRawCategoryPath() {
return rawCategoryPath;
}
public void setRawCategoryPath(String rawCategoryPath) {
this.rawCategoryPath = rawCategoryPath;
}
public PartCategory getPartCategory() {
return partCategory;
}
public void setPartCategory(PartCategory partCategory) {
this.partCategory = partCategory;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -1,24 +1,49 @@
package group.goforward.ballistic.model; package group.goforward.ballistic.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity @Entity
@Table(name = "part_categories") @Table(name = "part_categories")
public class PartCategory { public class PartCategory {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false) @Column(name = "id", nullable = false)
private Integer id; private Integer id;
@Column(name = "slug", nullable = false, length = Integer.MAX_VALUE) @Column(name = "slug", nullable = false, unique = true)
private String slug; private String slug;
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "name", nullable = false)
private String name; private String name;
@Column(name = "description", length = Integer.MAX_VALUE) @Column(name = "description")
private String description; private String description;
@ColumnDefault("gen_random_uuid()")
@Column(name = "uuid", nullable = false)
private UUID uuid;
@Column(name = "group_name")
private String groupName;
@Column(name = "sort_order")
private Integer sortOrder;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
// --- Getters & Setters ---
public Integer getId() { public Integer getId() {
return id; return id;
} }
@@ -51,4 +76,43 @@ public class PartCategory {
this.description = description; this.description = description;
} }
public UUID getUuid() {
return uuid;
}
public void setUuid(UUID uuid) {
this.uuid = uuid;
}
public String getGroupName() {
return groupName;
}
public void setGroupName(String groupName) {
this.groupName = groupName;
}
public Integer getSortOrder() {
return sortOrder;
}
public void setSortOrder(Integer sortOrder) {
this.sortOrder = sortOrder;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
} }

View File

@@ -0,0 +1,56 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "part_role_category_mappings",
uniqueConstraints = @UniqueConstraint(columnNames = {"platform", "part_role"}))
public class PartRoleCategoryMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "platform", nullable = false)
private String platform;
@Column(name = "part_role", nullable = false)
private String partRole;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_slug", referencedColumnName = "slug", nullable = false)
private PartCategory category;
@Column(name = "notes")
private String notes;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
// getters/setters…
public Integer getId() { return id; }
public void setId(Integer id) { this.id = id; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public String getPartRole() { return partRole; }
public void setPartRole(String partRole) { this.partRole = partRole; }
public PartCategory getCategory() { return category; }
public void setCategory(PartCategory category) { this.category = category; }
public String getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,65 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
@Entity
@Table(name = "part_role_mappings")
public class PartRoleMapping {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(nullable = false)
private String platform; // e.g. "AR-15"
@Column(name = "part_role", nullable = false)
private String partRole; // e.g. "UPPER", "BARREL", etc.
@ManyToOne(optional = false)
@JoinColumn(name = "part_category_id")
private PartCategory partCategory;
@Column(columnDefinition = "text")
private String notes;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getPlatform() {
return platform;
}
public void setPlatform(String platform) {
this.platform = platform;
}
public String getPartRole() {
return partRole;
}
public void setPartRole(String partRole) {
this.partRole = partRole;
}
public PartCategory getPartCategory() {
return partCategory;
}
public void setPartCategory(PartCategory partCategory) {
this.partCategory = partCategory;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}

View File

@@ -3,7 +3,13 @@ package group.goforward.ballistic.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.Instant; import java.time.Instant;
import java.util.UUID; import java.util.UUID;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.Objects;
import java.util.Set;
import java.util.HashSet;
import group.goforward.ballistic.model.ProductOffer;
import group.goforward.ballistic.model.ProductConfiguration; import group.goforward.ballistic.model.ProductConfiguration;
@Entity @Entity
@@ -68,7 +74,16 @@ public class Product {
@Column(name = "platform_locked", nullable = false) @Column(name = "platform_locked", nullable = false)
private Boolean platformLocked = false; private Boolean platformLocked = false;
@OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
private Set<ProductOffer> offers = new HashSet<>();
public Set<ProductOffer> getOffers() {
return offers;
}
public void setOffers(Set<ProductOffer> offers) {
this.offers = offers;
}
// --- lifecycle hooks --- // --- lifecycle hooks ---
@@ -236,4 +251,41 @@ public class Product {
public void setConfiguration(ProductConfiguration configuration) { public void setConfiguration(ProductConfiguration configuration) {
this.configuration = configuration; this.configuration = configuration;
} }
// Convenience: best offer price for Gunbuilder
public BigDecimal getBestOfferPrice() {
if (offers == null || offers.isEmpty()) {
return BigDecimal.ZERO;
}
return offers.stream()
// pick sale_price if present, otherwise retail_price
.map(offer -> {
if (offer.getSalePrice() != null) {
return offer.getSalePrice();
}
return offer.getRetailPrice();
})
.filter(Objects::nonNull)
.min(BigDecimal::compareTo)
.orElse(BigDecimal.ZERO);
}
// Convenience: URL for the best-priced offer
public String getBestOfferBuyUrl() {
if (offers == null || offers.isEmpty()) {
return null;
}
return offers.stream()
.sorted(Comparator.comparing(offer -> {
if (offer.getSalePrice() != null) {
return offer.getSalePrice();
}
return offer.getRetailPrice();
}, Comparator.nullsLast(BigDecimal::compareTo)))
.map(ProductOffer::getAffiliateUrl)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
}
} }

View File

@@ -7,11 +7,11 @@ import org.hibernate.annotations.OnDeleteAction;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.UUID;
@Entity @Entity
@Table(name = "product_offers") @Table(name = "product_offers")
public class ProductOffer { public class ProductOffer {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false) @Column(name = "id", nullable = false)
@@ -26,16 +26,16 @@ public class ProductOffer {
@JoinColumn(name = "merchant_id", nullable = false) @JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant; private Merchant merchant;
@Column(name = "avantlink_product_id", nullable = false, length = Integer.MAX_VALUE) @Column(name = "avantlink_product_id", nullable = false)
private String avantlinkProductId; private String avantlinkProductId;
@Column(name = "sku", length = Integer.MAX_VALUE) @Column(name = "sku")
private String sku; private String sku;
@Column(name = "upc", length = Integer.MAX_VALUE) @Column(name = "upc")
private String upc; private String upc;
@Column(name = "buy_url", nullable = false, length = Integer.MAX_VALUE) @Column(name = "buy_url", nullable = false)
private String buyUrl; private String buyUrl;
@Column(name = "price", nullable = false, precision = 10, scale = 2) @Column(name = "price", nullable = false, precision = 10, scale = 2)
@@ -45,7 +45,7 @@ public class ProductOffer {
private BigDecimal originalPrice; private BigDecimal originalPrice;
@ColumnDefault("'USD'") @ColumnDefault("'USD'")
@Column(name = "currency", nullable = false, length = Integer.MAX_VALUE) @Column(name = "currency", nullable = false)
private String currency; private String currency;
@ColumnDefault("true") @ColumnDefault("true")
@@ -60,6 +60,10 @@ public class ProductOffer {
@Column(name = "first_seen_at", nullable = false) @Column(name = "first_seen_at", nullable = false)
private OffsetDateTime firstSeenAt; private OffsetDateTime firstSeenAt;
// -----------------------------------------------------
// Getters & setters
// -----------------------------------------------------
public Integer getId() { public Integer getId() {
return id; return id;
} }
@@ -164,14 +168,26 @@ public class ProductOffer {
this.firstSeenAt = firstSeenAt; this.firstSeenAt = firstSeenAt;
} }
// -----------------------------------------------------
// Helper Methods (used by Product entity)
// -----------------------------------------------------
public BigDecimal getSalePrice() {
return price;
}
public BigDecimal getRetailPrice() {
return originalPrice != null ? originalPrice : price;
}
public String getAffiliateUrl() {
return buyUrl;
}
public BigDecimal getEffectivePrice() { public BigDecimal getEffectivePrice() {
// Prefer a true sale price when it's lower than the original
if (price != null && originalPrice != null && price.compareTo(originalPrice) < 0) { if (price != null && originalPrice != null && price.compareTo(originalPrice) < 0) {
return price; return price;
} }
// Otherwise, use whatever is available
return price != null ? price : originalPrice; return price != null ? price : originalPrice;
} }
} }

View File

@@ -1,298 +1,222 @@
package group.goforward.ballistic.model; package group.goforward.ballistic.model;
import jakarta.persistence.Column; import jakarta.persistence.*;
import jakarta.persistence.Entity; import jakarta.validation.constraints.NotNull;
import jakarta.persistence.Id; import org.hibernate.annotations.ColumnDefault;
import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault; import java.time.OffsetDateTime;
import java.util.UUID;
import java.time.Instant;
import java.time.LocalDate; @Entity
import java.util.UUID; @Table(name = "users")
public class User {
@Entity
@Table(name = "users") @Id
public class User { @GeneratedValue(strategy = GenerationType.IDENTITY)
@Id @Column(name = "id", nullable = false)
@Column(name = "id", nullable = false, length = 21) private Integer id;
private String id;
@NotNull
@Column(name = "name", length = Integer.MAX_VALUE) @ColumnDefault("gen_random_uuid()")
private String name; @Column(name = "uuid", nullable = false)
private UUID uuid;
@Column(name = "username", length = 50)
private String username; @NotNull
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
@Column(name = "email", nullable = false) private String email;
private String email;
@NotNull
@Column(name = "first_name", length = 50) @Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
private String firstName; private String passwordHash;
@Column(name = "last_name", length = 50) @Column(name = "display_name", length = Integer.MAX_VALUE)
private String lastName; private String displayName;
@Column(name = "full_name", length = 50) @NotNull
private String fullName; @ColumnDefault("'USER'")
@Column(name = "role", nullable = false, length = Integer.MAX_VALUE)
@Column(name = "profile_picture") private String role;
private String profilePicture;
@NotNull
@Column(name = "image", length = Integer.MAX_VALUE) @ColumnDefault("true")
private String image; @Column(name = "is_active", nullable = false)
private boolean isActive = true;
@Column(name = "date_of_birth")
private LocalDate dateOfBirth; @NotNull
@ColumnDefault("now()")
@Column(name = "phone_number", length = 20) @Column(name = "created_at", nullable = false)
private String phoneNumber; private OffsetDateTime createdAt;
@ColumnDefault("CURRENT_TIMESTAMP") @NotNull
@Column(name = "created_at") @ColumnDefault("now()")
private Instant createdAt; @Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@ColumnDefault("CURRENT_TIMESTAMP")
@Column(name = "updated_at") @Column(name = "deleted_at")
private Instant updatedAt; private OffsetDateTime deletedAt;
@ColumnDefault("false") // NEW FIELDS
@Column(name = "is_admin")
private Boolean isAdmin; @Column(name = "email_verified_at")
private OffsetDateTime emailVerifiedAt;
@Column(name = "last_login")
private Instant lastLogin; @Column(name = "verification_token", length = Integer.MAX_VALUE)
private String verificationToken;
@ColumnDefault("false")
@Column(name = "email_verified", nullable = false) @Column(name = "reset_password_token", length = Integer.MAX_VALUE)
private Boolean emailVerified = false; private String resetPasswordToken;
@ColumnDefault("'public'") @Column(name = "reset_password_expires_at")
@Column(name = "build_privacy_setting", length = Integer.MAX_VALUE) private OffsetDateTime resetPasswordExpiresAt;
private String buildPrivacySetting;
@Column(name = "last_login_at")
@ColumnDefault("gen_random_uuid()") private OffsetDateTime lastLoginAt;
@Column(name = "uuid")
private UUID uuid; @ColumnDefault("0")
@Column(name = "login_count", nullable = false)
@Column(name = "discord_id") private Integer loginCount = 0;
private String discordId;
// --- Getters / setters ---
@Column(name = "hashed_password")
private String hashedPassword; public Integer getId() {
return id;
@Column(name = "avatar") }
private String avatar;
public void setId(Integer id) {
@Column(name = "stripe_subscription_id", length = 191) this.id = id;
private String stripeSubscriptionId; }
@Column(name = "stripe_price_id", length = 191) public UUID getUuid() {
private String stripePriceId; return uuid;
}
@Column(name = "stripe_customer_id", length = 191)
private String stripeCustomerId; public void setUuid(UUID uuid) {
this.uuid = uuid;
@Column(name = "stripe_current_period_end") }
private Instant stripeCurrentPeriodEnd;
public String getEmail() {
public String getId() { return email;
return id; }
}
public void setEmail(String email) {
public void setId(String id) { this.email = email;
this.id = id; }
}
public String getPasswordHash() {
public String getName() { return passwordHash;
return name; }
}
public void setPasswordHash(String passwordHash) {
public void setName(String name) { this.passwordHash = passwordHash;
this.name = name; }
}
public String getDisplayName() {
public String getUsername() { return displayName;
return username; }
}
public void setDisplayName(String displayName) {
public void setUsername(String username) { this.displayName = displayName;
this.username = username; }
}
public String getRole() {
public String getEmail() { return role;
return email; }
}
public void setRole(String role) {
public void setEmail(String email) { this.role = role;
this.email = email; }
}
public boolean getIsActive() {
public String getFirstName() { return isActive;
return firstName; }
}
public void setIsActive(boolean active) {
public void setFirstName(String firstName) { isActive = active;
this.firstName = firstName; }
}
public OffsetDateTime getCreatedAt() {
public String getLastName() { return createdAt;
return lastName; }
}
public void setCreatedAt(OffsetDateTime createdAt) {
public void setLastName(String lastName) { this.createdAt = createdAt;
this.lastName = lastName; }
}
public OffsetDateTime getUpdatedAt() {
public String getFullName() { return updatedAt;
return fullName; }
}
public void setUpdatedAt(OffsetDateTime updatedAt) {
public void setFullName(String fullName) { this.updatedAt = updatedAt;
this.fullName = fullName; }
}
public OffsetDateTime getDeletedAt() {
public String getProfilePicture() { return deletedAt;
return profilePicture; }
}
public void setDeletedAt(OffsetDateTime deletedAt) {
public void setProfilePicture(String profilePicture) { this.deletedAt = deletedAt;
this.profilePicture = profilePicture; }
}
public OffsetDateTime getEmailVerifiedAt() {
public String getImage() { return emailVerifiedAt;
return image; }
}
public void setEmailVerifiedAt(OffsetDateTime emailVerifiedAt) {
public void setImage(String image) { this.emailVerifiedAt = emailVerifiedAt;
this.image = image; }
}
public String getVerificationToken() {
public LocalDate getDateOfBirth() { return verificationToken;
return dateOfBirth; }
}
public void setVerificationToken(String verificationToken) {
public void setDateOfBirth(LocalDate dateOfBirth) { this.verificationToken = verificationToken;
this.dateOfBirth = dateOfBirth; }
}
public String getResetPasswordToken() {
public String getPhoneNumber() { return resetPasswordToken;
return phoneNumber; }
}
public void setResetPasswordToken(String resetPasswordToken) {
public void setPhoneNumber(String phoneNumber) { this.resetPasswordToken = resetPasswordToken;
this.phoneNumber = phoneNumber; }
}
public OffsetDateTime getResetPasswordExpiresAt() {
public Instant getCreatedAt() { return resetPasswordExpiresAt;
return createdAt; }
}
public void setResetPasswordExpiresAt(OffsetDateTime resetPasswordExpiresAt) {
public void setCreatedAt(Instant createdAt) { this.resetPasswordExpiresAt = resetPasswordExpiresAt;
this.createdAt = createdAt; }
}
public OffsetDateTime getLastLoginAt() {
public Instant getUpdatedAt() { return lastLoginAt;
return updatedAt; }
}
public void setLastLoginAt(OffsetDateTime lastLoginAt) {
public void setUpdatedAt(Instant updatedAt) { this.lastLoginAt = lastLoginAt;
this.updatedAt = updatedAt; }
}
public Integer getLoginCount() {
public Boolean getIsAdmin() { return loginCount;
return isAdmin; }
}
public void setLoginCount(Integer loginCount) {
public void setIsAdmin(Boolean isAdmin) { this.loginCount = loginCount;
this.isAdmin = isAdmin; }
}
// convenience helpers
public Instant getLastLogin() {
return lastLogin; @Transient
} public boolean isEmailVerified() {
return emailVerifiedAt != null;
public void setLastLogin(Instant lastLogin) { }
this.lastLogin = lastLogin;
} public void incrementLoginCount() {
if (loginCount == null) {
public Boolean getEmailVerified() { loginCount = 0;
return emailVerified; }
} loginCount++;
}
public void setEmailVerified(Boolean emailVerified) {
this.emailVerified = emailVerified;
}
public String getBuildPrivacySetting() {
return buildPrivacySetting;
}
public void setBuildPrivacySetting(String buildPrivacySetting) {
this.buildPrivacySetting = buildPrivacySetting;
}
public UUID getUuid() {
return uuid;
}
public void setUuid(UUID uuid) {
this.uuid = uuid;
}
public String getDiscordId() {
return discordId;
}
public void setDiscordId(String discordId) {
this.discordId = discordId;
}
public String getHashedPassword() {
return hashedPassword;
}
public void setHashedPassword(String hashedPassword) {
this.hashedPassword = hashedPassword;
}
public String getAvatar() {
return avatar;
}
public void setAvatar(String avatar) {
this.avatar = avatar;
}
public String getStripeSubscriptionId() {
return stripeSubscriptionId;
}
public void setStripeSubscriptionId(String stripeSubscriptionId) {
this.stripeSubscriptionId = stripeSubscriptionId;
}
public String getStripePriceId() {
return stripePriceId;
}
public void setStripePriceId(String stripePriceId) {
this.stripePriceId = stripePriceId;
}
public String getStripeCustomerId() {
return stripeCustomerId;
}
public void setStripeCustomerId(String stripeCustomerId) {
this.stripeCustomerId = stripeCustomerId;
}
public Instant getStripeCurrentPeriodEnd() {
return stripeCurrentPeriodEnd;
}
public void setStripeCurrentPeriodEnd(Instant stripeCurrentPeriodEnd) {
this.stripeCurrentPeriodEnd = stripeCurrentPeriodEnd;
}
} }

View File

@@ -1,8 +1,9 @@
package group.goforward.ballistic.model; package group.goforward.ballistic.repos;
import org.springframework.data.jpa.repository.JpaRepository; import group.goforward.ballistic.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
import java.util.UUID;
public interface AccountRepository extends JpaRepository<Account, UUID> {
public interface AccountRepository extends JpaRepository<Account, UUID> {
} }

View File

@@ -1,7 +1,22 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.AffiliateCategoryMap; import group.goforward.ballistic.model.CategoryMapping;
import group.goforward.ballistic.model.Merchant;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface CategoryMappingRepository extends JpaRepository<AffiliateCategoryMap, Integer> { import java.util.List;
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
// All mappings for a merchant, ordered nicely
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
// Merchants that actually have mappings (for the dropdown)
@Query("""
select distinct cm.merchant
from CategoryMapping cm
order by cm.merchant.name asc
""")
List<Merchant> findDistinctMerchantsWithMappings();
} }

View File

@@ -2,8 +2,13 @@ package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.PartCategory; import group.goforward.ballistic.model.PartCategory;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> { public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
Optional<PartCategory> findBySlug(String slug); Optional<PartCategory> findBySlug(String slug);
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
} }

View File

@@ -0,0 +1,14 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.PartRoleCategoryMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface PartRoleCategoryMappingRepository extends JpaRepository<PartRoleCategoryMapping, Integer> {
List<PartRoleCategoryMapping> findAllByPlatformOrderByPartRoleAsc(String platform);
Optional<PartRoleCategoryMapping> findByPlatformAndPartRole(String platform, String partRole);
}

View File

@@ -0,0 +1,12 @@
package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.PartRoleMapping;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PartRoleMappingRepository extends JpaRepository<PartRoleMapping, Integer> {
// List mappings for a platform, ordered nicely for the UI
List<PartRoleMapping> findByPlatformOrderByPartRoleAsc(String platform);
}

View File

@@ -1,27 +1,62 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.Product;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
import java.util.UUID;
import java.util.List; import java.util.List;
import java.util.Collection;
public interface ProductRepository extends JpaRepository<Product, Integer> { public interface ProductRepository extends JpaRepository<Product, Integer> {
Optional<Product> findByUuid(UUID uuid); // -------------------------------------------------
// Used by MerchantFeedImportServiceImpl
boolean existsBySlug(String slug); // -------------------------------------------------
List<Product> findAllByBrandAndMpn(Brand brand, String mpn); List<Product> findAllByBrandAndMpn(Brand brand, String mpn);
List<Product> findAllByBrandAndUpc(Brand brand, String upc); List<Product> findAllByBrandAndUpc(Brand brand, String upc);
// All products for a given platform (e.g. "AR-15") boolean existsBySlug(String slug);
List<Product> findByPlatform(String platform);
// Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) // -------------------------------------------------
List<Product> findByPlatformAndPartRoleIn(String platform, Collection<String> partRoles); // Used by ProductController for platform views
// -------------------------------------------------
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.deletedAt IS NULL
""")
List<Product> findByPlatformWithBrand(@Param("platform") String platform);
@Query("""
SELECT p
FROM Product p
JOIN FETCH p.brand b
WHERE p.platform = :platform
AND p.partRole IN :roles
AND p.deletedAt IS NULL
""")
List<Product> findByPlatformAndPartRoleInWithBrand(
@Param("platform") String platform,
@Param("roles") List<String> roles
);
// -------------------------------------------------
// Used by Gunbuilder service (if you wired this)
// -------------------------------------------------
@Query("""
SELECT DISTINCT p
FROM Product p
LEFT JOIN FETCH p.brand b
LEFT JOIN FETCH p.offers o
WHERE p.platform = :platform
AND p.deletedAt IS NULL
""")
List<Product> findSomethingForGunbuilder(@Param("platform") String platform);
} }

View File

@@ -2,10 +2,15 @@ package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.User; import group.goforward.ballistic.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface UserRepository extends JpaRepository<User, Integer> { public interface UserRepository extends JpaRepository<User, Integer> {
Optional<User> findByEmail(String email);
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
Optional<User> findByUuid(UUID uuid); Optional<User> findByUuid(UUID uuid);
} }

View File

@@ -0,0 +1,59 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.List;
public class CustomUserDetails implements UserDetails {
private final User user;
private final List<GrantedAuthority> authorities;
public CustomUserDetails(User user) {
this.user = user;
this.authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
}
public User getUser() {
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return user.getPasswordHash();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return user.getDeletedAt() == null;
}
@Override
public boolean isAccountNonLocked() {
return user.getIsActive();
}
@Override
public boolean isCredentialsNonExpired() {
return user.getDeletedAt() == null;
}
@Override
public boolean isEnabled() {
return user.getIsActive() && user.getDeletedAt() == null;
}
}

View File

@@ -0,0 +1,25 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository users;
public CustomUserDetailsService(UserRepository users) {
this.users = users;
}
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return new CustomUserDetails(user);
}
}

View File

@@ -0,0 +1,26 @@
package group.goforward.ballistic.security;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException
) throws IOException, ServletException {
// Simple JSON 401 response
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Unauthorized\"}");
}
}

View File

@@ -0,0 +1,80 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final UserRepository userRepository;
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
this.jwtService = jwtService;
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = authHeader.substring(7);
if (!jwtService.isTokenValid(token)) {
filterChain.doFilter(request, response);
return;
}
UUID userUuid = jwtService.extractUserUuid(token);
if (userUuid == null || SecurityContextHolder.getContext().getAuthentication() != null) {
filterChain.doFilter(request, response);
return;
}
User user = userRepository.findByUuid(userUuid)
.orElse(null);
if (user == null || !user.getIsActive()) {
filterChain.doFilter(request, response);
return;
}
CustomUserDetails userDetails = new CustomUserDetails(user);
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,71 @@
package group.goforward.ballistic.security;
import group.goforward.ballistic.model.User;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.security.Key;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
@Service
public class JwtService {
private final Key key;
private final long accessTokenMinutes;
public JwtService(
@Value("${security.jwt.secret}") String secret,
@Value("${security.jwt.access-token-minutes:60}") long accessTokenMinutes
) {
this.key = Keys.hmacShaKeyFor(secret.getBytes());
this.accessTokenMinutes = accessTokenMinutes;
}
public String generateToken(User user) {
Instant now = Instant.now();
Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES);
return Jwts.builder()
.setSubject(user.getUuid().toString())
.setIssuedAt(Date.from(now))
.setExpiration(Date.from(expiry))
.addClaims(Map.of(
"email", user.getEmail(),
"role", user.getRole(),
"displayName", user.getDisplayName()
))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public UUID extractUserUuid(String token) {
Claims claims = parseClaims(token);
return UUID.fromString(claims.getSubject());
}
public boolean isTokenValid(String token) {
try {
parseClaims(token);
return true;
} catch (JwtException | IllegalArgumentException ex) {
return false;
}
}
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
}

View File

@@ -0,0 +1,57 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.repos.ProductRepository;
import group.goforward.ballistic.web.dto.GunbuilderProductDto;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class GunbuilderProductService {
private final ProductRepository productRepository;
private final PartCategoryResolverService partCategoryResolverService;
public GunbuilderProductService(
ProductRepository productRepository,
PartCategoryResolverService partCategoryResolverService
) {
this.productRepository = productRepository;
this.partCategoryResolverService = partCategoryResolverService;
}
public List<GunbuilderProductDto> listGunbuilderProducts(String platform) {
List<Product> products = productRepository.findSomethingForGunbuilder(platform);
return products.stream()
.map(p -> {
var maybeCategory = partCategoryResolverService
.resolveForPlatformAndPartRole(platform, p.getPartRole());
if (maybeCategory.isEmpty()) {
// you can also log here
return null;
}
PartCategory cat = maybeCategory.get();
return new GunbuilderProductDto(
p.getId(),
p.getName(),
p.getBrand().getName(),
platform,
p.getPartRole(),
p.getBestOfferPrice(),
p.getMainImageUrl(),
p.getBestOfferBuyUrl(),
cat.getSlug(),
cat.getGroupName()
);
})
.filter(dto -> dto != null)
.toList();
}
}

View File

@@ -0,0 +1,41 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.PartCategory;
import group.goforward.ballistic.repos.PartCategoryRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class PartCategoryResolverService {
private final PartCategoryRepository partCategoryRepository;
public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) {
this.partCategoryRepository = partCategoryRepository;
}
/**
* Resolve the canonical PartCategory for a given platform + partRole.
*
* For now we keep it simple:
* - We treat partRole as the slug (e.g. "barrel", "upper", "trigger").
* - Normalize to lower-kebab (spaces -> dashes, lowercased).
* - Look up by slug in part_categories.
*
* Later, if we want per-merchant / per-platform overrides using category_mappings,
* we can extend this method without changing callers.
*/
public Optional<PartCategory> resolveForPlatformAndPartRole(String platform, String partRole) {
if (partRole == null || partRole.isBlank()) {
return Optional.empty();
}
String normalizedSlug = partRole
.trim()
.toLowerCase()
.replace(" ", "-");
return partCategoryRepository.findBySlug(normalizedSlug);
}
}

View File

@@ -1,40 +1,17 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.Psa; import group.goforward.ballistic.model.Psa;
import group.goforward.ballistic.repos.PsaRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Service public interface PsaService {
public class PsaService implements group.goforward.ballistic.services.impl.PsaService { List<Psa> findAll();
private final PsaRepository psaRepository; Optional<Psa> findById(UUID id);
@Autowired Psa save(Psa psa);
public PsaService(PsaRepository psaRepository) {
this.psaRepository = psaRepository;
}
@Override void deleteById(UUID id);
public List<Psa> findAll() {
return psaRepository.findAll();
}
@Override
public Optional<Psa> findById(UUID id) {
return psaRepository.findById(id);
}
@Override
public Psa save(Psa psa) {
return psaRepository.save(psa);
}
@Override
public void deleteById(UUID id) {
psaRepository.deleteById(id);
}
} }

View File

@@ -0,0 +1,16 @@
package group.goforward.ballistic.services;
import group.goforward.ballistic.model.User;
import java.util.List;
import java.util.Optional;
public interface UsersService {
List<User> findAll();
Optional<User> findById(Integer id);
User save(User item);
void deleteById(Integer id);
}

View File

@@ -11,12 +11,15 @@ import java.io.BufferedReader;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import org.springframework.cache.annotation.CacheEvict;
import group.goforward.ballistic.imports.MerchantFeedRow; import group.goforward.ballistic.imports.MerchantFeedRow;
import group.goforward.ballistic.services.MerchantFeedImportService; import group.goforward.ballistic.services.MerchantFeedImportService;
import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVParser; import org.apache.commons.csv.CSVParser;
import org.apache.commons.csv.CSVRecord; import org.apache.commons.csv.CSVRecord;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
@@ -36,6 +39,7 @@ import java.time.OffsetDateTime;
@Service @Service
@Transactional @Transactional
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService { public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
private final BrandRepository brandRepository; private final BrandRepository brandRepository;
@@ -56,27 +60,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
} }
@Override @Override
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
public void importMerchantFeed(Integer merchantId) { public void importMerchantFeed(Integer merchantId) {
System.out.println("IMPORT >>> importMerchantFeed(" + merchantId + ")"); log.info("Starting full import for merchantId={}", merchantId);
Merchant merchant = merchantRepository.findById(merchantId) Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId)); .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
// Read all rows from the merchant feed // Read all rows from the merchant feed
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant); List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
System.out.println("IMPORT >>> read " + rows.size() + " rows for merchant=" + merchant.getName()); log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
for (MerchantFeedRow row : rows) { for (MerchantFeedRow row : rows) {
// Resolve brand from the row (fallback to "Aero Precision" or whatever you want as default)
Brand brand = resolveBrand(row); Brand brand = resolveBrand(row);
Product p = upsertProduct(merchant, brand, row); Product p = upsertProduct(merchant, brand, row);
log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}",
System.out.println("IMPORT >>> upserted product id=" + p.getId() p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
+ ", name=" + p.getName()
+ ", slug=" + p.getSlug()
+ ", platform=" + p.getPlatform()
+ ", partRole=" + p.getPartRole()
+ ", merchant=" + merchant.getName());
} }
} }
@@ -85,9 +84,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) { private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
System.out.println("IMPORT >>> upsertProduct brand=" + brand.getName() log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName());
+ ", sku=" + row.sku()
+ ", productName=" + row.productName());
String mpn = trimOrNull(row.manufacturerId()); String mpn = trimOrNull(row.manufacturerId());
String upc = trimOrNull(row.sku()); // placeholder until real UPC field String upc = trimOrNull(row.sku()); // placeholder until real UPC field
@@ -109,9 +106,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
p.setBrand(brand); p.setBrand(brand);
} else { } else {
if (candidates.size() > 1) { if (candidates.size() > 1) {
System.out.println("IMPORT !!! WARNING: multiple existing products found for brand=" log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}",
+ brand.getName() + ", mpn=" + mpn + ", upc=" + upc brand.getName(), mpn, upc, candidates.get(0).getId());
+ ". Using the first match (id=" + candidates.get(0).getId() + ")");
} }
p = candidates.get(0); p = candidates.get(0);
} }
@@ -127,10 +123,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return saved; return saved;
} }
private List<Map<String, String>> fetchFeedRows(String feedUrl) { private List<Map<String, String>> fetchFeedRows(String feedUrl) {
System.out.println("OFFERS >>> reading offer feed from: " + feedUrl); log.info("Reading offer feed from {}", feedUrl);
List<Map<String, String>> rows = new ArrayList<>(); List<Map<String, String>> rows = new ArrayList<>();
try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://"))
? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8)
: java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8);
@@ -139,10 +135,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
.withIgnoreSurroundingSpaces() .withIgnoreSurroundingSpaces()
.withTrim() .withTrim()
.parse(reader)) { .parse(reader)) {
// capture header names from the CSV // capture header names from the CSV
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet()); List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) { for (CSVRecord rec : parser) {
Map<String, String> row = new HashMap<>(); Map<String, String> row = new HashMap<>();
for (String header : headers) { for (String header : headers) {
@@ -153,8 +149,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
} catch (Exception ex) { } catch (Exception ex) {
throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex);
} }
System.out.println("OFFERS >>> parsed " + rows.size() + " offer rows"); log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl);
return rows; return rows;
} }
@@ -255,7 +251,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
String avantlinkProductId = trimOrNull(row.sku()); String avantlinkProductId = trimOrNull(row.sku());
if (avantlinkProductId == null) { if (avantlinkProductId == null) {
// If there's truly no SKU, bail out we can't match this offer reliably. // If there's truly no SKU, bail out we can't match this offer reliably.
System.out.println("IMPORT !!! skipping offer: no SKU for product id=" + product.getId()); log.debug("Skipping offer row with no SKU for product id={}", product.getId());
return; return;
} }
@@ -358,11 +354,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
Map<String, Integer> headerMap = parser.getHeaderMap(); Map<String, Integer> headerMap = parser.getHeaderMap();
if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) { if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) {
System.out.println( log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl);
"IMPORT >>> detected delimiter '" +
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
"' for feed: " + feedUrl
);
return CSVFormat.DEFAULT.builder() return CSVFormat.DEFAULT.builder()
.setDelimiter(delimiter) .setDelimiter(delimiter)
@@ -372,16 +364,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
.setTrim(true) .setTrim(true)
.build(); .build();
} else if (headerMap != null) { } else if (headerMap != null) {
System.out.println( log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl);
"IMPORT !!! delimiter '" +
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
"' produced headers: " + headerMap.keySet()
);
} }
} catch (Exception ex) { } catch (Exception ex) {
lastException = ex; lastException = ex;
System.out.println("IMPORT !!! error probing delimiter '" + delimiter + log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage());
"' for " + feedUrl + ": " + ex.getMessage());
} }
} }
@@ -398,7 +385,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
} }
String feedUrl = rawFeedUrl.trim(); String feedUrl = rawFeedUrl.trim();
System.out.println("IMPORT >>> reading feed for merchant=" + merchant.getName() + " from: " + feedUrl); log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl);
List<MerchantFeedRow> rows = new ArrayList<>(); List<MerchantFeedRow> rows = new ArrayList<>();
@@ -409,7 +396,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
try (Reader reader = openFeedReader(feedUrl); try (Reader reader = openFeedReader(feedUrl);
CSVParser parser = new CSVParser(reader, format)) { CSVParser parser = new CSVParser(reader, format)) {
System.out.println("IMPORT >>> detected headers: " + parser.getHeaderMap().keySet()); log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet());
for (CSVRecord rec : parser) { for (CSVRecord rec : parser) {
MerchantFeedRow row = new MerchantFeedRow( MerchantFeedRow row = new MerchantFeedRow(
@@ -447,7 +434,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
+ merchant.getName() + " from " + feedUrl, ex); + merchant.getName() + " from " + feedUrl, ex);
} }
System.out.println("IMPORT >>> parsed " + rows.size() + " rows for merchant=" + merchant.getName()); log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName());
return rows; return rows;
} }
@@ -474,7 +461,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
try { try {
return new BigDecimal(trimmed); return new BigDecimal(trimmed);
} catch (NumberFormatException ex) { } catch (NumberFormatException ex) {
System.out.println("IMPORT !!! bad BigDecimal value: '" + raw + "', skipping"); log.debug("Skipping invalid numeric value '{}'", raw);
return null; return null;
} }
} }
@@ -495,8 +482,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
try { try {
return rec.get(header); return rec.get(header);
} catch (IllegalArgumentException ex) { } catch (IllegalArgumentException ex) {
System.out.println("IMPORT !!! short record #" + rec.getRecordNumber() log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header);
+ " missing column '" + header + "', treating as null");
return null; return null;
} }
} }
@@ -593,32 +579,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
return "unknown"; return "unknown";
} }
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
public void syncOffersOnly(Integer merchantId) { public void syncOffersOnly(Integer merchantId) {
log.info("Starting offers-only sync for merchantId={}", merchantId);
Merchant merchant = merchantRepository.findById(merchantId) Merchant merchant = merchantRepository.findById(merchantId)
.orElseThrow(() -> new RuntimeException("Merchant not found")); .orElseThrow(() -> new RuntimeException("Merchant not found"));
if (Boolean.FALSE.equals(merchant.getIsActive())) { if (Boolean.FALSE.equals(merchant.getIsActive())) {
return; return;
} }
// Use offerFeedUrl if present, else fall back to feedUrl
String feedUrl = merchant.getOfferFeedUrl() != null String feedUrl = merchant.getOfferFeedUrl() != null
? merchant.getOfferFeedUrl() ? merchant.getOfferFeedUrl()
: merchant.getFeedUrl(); : merchant.getFeedUrl();
if (feedUrl == null) { if (feedUrl == null) {
throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); throw new RuntimeException("No offer feed URL configured for merchant " + merchantId);
} }
List<Map<String, String>> rows = fetchFeedRows(feedUrl); List<Map<String, String>> rows = fetchFeedRows(feedUrl);
for (Map<String, String> row : rows) { for (Map<String, String> row : rows) {
upsertOfferOnlyFromRow(merchant, row); upsertOfferOnlyFromRow(merchant, row);
} }
merchant.setLastOfferSyncAt(OffsetDateTime.now()); merchant.setLastOfferSyncAt(OffsetDateTime.now());
merchantRepository.save(merchant); merchantRepository.save(merchant);
log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size());
} }
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) { private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
// For the offer-only sync, we key offers by the same identifier we used when creating them. // For the offer-only sync, we key offers by the same identifier we used when creating them.
// In the current AvantLink-style feed, that is the SKU column. // In the current AvantLink-style feed, that is the SKU column.

View File

@@ -1,17 +0,0 @@
package group.goforward.ballistic.services.impl;
import group.goforward.ballistic.model.Psa;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface PsaService {
List<Psa> findAll();
Optional<Psa> findById(UUID id);
Psa save(Psa psa);
void deleteById(UUID id);
}

View File

@@ -0,0 +1,41 @@
package group.goforward.ballistic.services.impl;
import group.goforward.ballistic.model.Psa;
import group.goforward.ballistic.repos.PsaRepository;
import group.goforward.ballistic.services.PsaService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
public class PsaServiceImpl implements PsaService {
private final PsaRepository psaRepository;
@Autowired
public PsaServiceImpl(PsaRepository psaRepository) {
this.psaRepository = psaRepository;
}
@Override
public List<Psa> findAll() {
return psaRepository.findAll();
}
@Override
public Optional<Psa> findById(UUID id) {
return psaRepository.findById(id);
}
@Override
public Psa save(Psa psa) {
return psaRepository.save(psa);
}
@Override
public void deleteById(UUID id) {
psaRepository.deleteById(id);
}
}

View File

@@ -0,0 +1,37 @@
package group.goforward.ballistic.services.impl;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
import group.goforward.ballistic.services.UsersService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
@Service
public class UsersServiceImpl implements UsersService {
@Autowired
private UserRepository repo;
@Override
public List<User> findAll() {
return repo.findAll();
}
@Override
public Optional<User> findById(Integer id) {
return repo.findById(id);
}
@Override
public User save(User item) {
return null;
}
@Override
public void deleteById(Integer id) {
deleteById(id);
}
}

View File

@@ -0,0 +1,53 @@
package group.goforward.ballistic.web.dto;
import java.math.BigDecimal;
public class GunbuilderProductDto {
private Integer id;
private String name;
private String brand;
private String platform;
private String partRole;
private BigDecimal price;
private String imageUrl;
private String buyUrl;
private String categorySlug;
private String categoryGroup;
public GunbuilderProductDto(
Integer id,
String name,
String brand,
String platform,
String partRole,
BigDecimal price,
String imageUrl,
String buyUrl,
String categorySlug,
String categoryGroup
) {
this.id = id;
this.name = name;
this.brand = brand;
this.platform = platform;
this.partRole = partRole;
this.price = price;
this.imageUrl = imageUrl;
this.buyUrl = buyUrl;
this.categorySlug = categorySlug;
this.categoryGroup = categoryGroup;
}
// --- Getters only (DTOs are read-only in most cases) ---
public Integer getId() { return id; }
public String getName() { return name; }
public String getBrand() { return brand; }
public String getPlatform() { return platform; }
public String getPartRole() { return partRole; }
public BigDecimal getPrice() { return price; }
public String getImageUrl() { return imageUrl; }
public String getBuyUrl() { return buyUrl; }
public String getCategorySlug() { return categorySlug; }
public String getCategoryGroup() { return categoryGroup; }
}

View File

@@ -0,0 +1,10 @@
package group.goforward.ballistic.web.dto.admin;
public record AdminPartRoleMappingDto(
Integer id,
String platform,
String partRole,
String categorySlug,
String groupName,
String notes
) {}

View File

@@ -0,0 +1,8 @@
package group.goforward.ballistic.web.dto.admin;
public record CreatePartRoleMappingRequest(
String platform,
String partRole,
String categorySlug,
String notes
) {}

View File

@@ -0,0 +1,11 @@
// src/main/java/group/goforward/ballistic/web/dto/admin/MerchantCategoryMappingDto.java
package group.goforward.ballistic.web.dto.admin;
public record MerchantCategoryMappingDto(
Integer id,
Integer merchantId,
String merchantName,
String rawCategoryPath,
Integer partCategoryId,
String partCategoryName
) {}

View File

@@ -0,0 +1,10 @@
package group.goforward.ballistic.web.dto.admin;
public record PartCategoryDto(
Integer id,
String slug,
String name,
String description,
String groupName,
Integer sortOrder
) {}

View File

@@ -0,0 +1,10 @@
package group.goforward.ballistic.web.dto.admin;
public record PartRoleMappingDto(
Integer id,
String platform,
String partRole,
String categorySlug,
String groupName,
String notes
) {}

View File

@@ -0,0 +1,8 @@
package group.goforward.ballistic.web.dto.admin;
public record PartRoleMappingRequest(
String platform,
String partRole,
String categorySlug,
String notes
) {}

View File

@@ -0,0 +1,6 @@
package group.goforward.ballistic.web.dto.admin;
public record SimpleMerchantDto(
Integer id,
String name
) { }

View File

@@ -0,0 +1,5 @@
package group.goforward.ballistic.web.dto.admin;
public record UpdateMerchantCategoryMappingRequest(
Integer partCategoryId
) {}

View File

@@ -0,0 +1,8 @@
package group.goforward.ballistic.web.dto.admin;
public record UpdatePartRoleMappingRequest(
String platform,
String partRole,
String categorySlug,
String notes
) {}

View File

@@ -0,0 +1,49 @@
package group.goforward.ballistic.web.dto.auth;
public class AuthResponse {
private String token;
private String email;
private String displayName;
private String role;
public AuthResponse() {}
public AuthResponse(String token, String email, String displayName, String role) {
this.token = token;
this.email = email;
this.displayName = displayName;
this.role = role;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

View File

@@ -0,0 +1,22 @@
package group.goforward.ballistic.web.dto.auth;
public class LoginRequest {
private String email;
private String password;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@@ -0,0 +1,31 @@
package group.goforward.ballistic.web.dto.auth;
public class RegisterRequest {
private String email;
private String password;
private String displayName;
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
}

View File

@@ -9,3 +9,6 @@ spring.datasource.driver-class-name=org.postgresql.Driver
#spring.jpa.hibernate.ddl-auto=update #spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
security.jwt.secret=ballistic-test-secret-key-1234567890-ABCDEFGHIJKLNMOPQRST
security.jwt.access-token-minutes=2880

View File

@@ -3,7 +3,7 @@ package group.goforward.ballistic;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest // @SpringBootTest
class BallisticApplicationTests { class BallisticApplicationTests {
@Test @Test