mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2025-12-05 18:46:44 -05:00
Compare commits
3 Commits
346ccc3813
...
7e1b33efdf
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e1b33efdf | |||
| 009e512a66 | |||
| 9fabf30406 |
@@ -1,5 +1,5 @@
|
||||
# Ballistic Backend
|
||||
### Internal Engine for the Builder Ecosystem
|
||||
# Ballistic Builder ( The Armory?) Backend
|
||||
### 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.
|
||||
|
||||
|
||||
17
docker/bb-spring/Dockerfile
Normal file
17
docker/bb-spring/Dockerfile
Normal 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"]
|
||||
63
docker/docker-compose.yaml
Normal file
63
docker/docker-compose.yaml
Normal 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
|
||||
22
docker/ss_builder/Dockerfile
Normal file
22
docker/ss_builder/Dockerfile
Normal 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"]
|
||||
@@ -5,8 +5,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableCaching
|
||||
@ComponentScan("group.goforward.ballistic.controllers")
|
||||
@ComponentScan("group.goforward.ballistic.repos")
|
||||
@ComponentScan("group.goforward.ballistic.services")
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -7,6 +7,7 @@ import group.goforward.ballistic.web.dto.ProductOfferDto;
|
||||
import group.goforward.ballistic.repos.ProductRepository;
|
||||
import group.goforward.ballistic.web.dto.ProductSummaryDto;
|
||||
import group.goforward.ballistic.web.mapper.ProductMapper;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -30,35 +31,54 @@ public class ProductController {
|
||||
}
|
||||
|
||||
@GetMapping("/gunbuilder")
|
||||
@Cacheable(
|
||||
value = "gunbuilderProducts",
|
||||
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
|
||||
)
|
||||
public List<ProductSummaryDto> getGunbuilderProducts(
|
||||
@RequestParam(defaultValue = "AR-15") String platform,
|
||||
@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;
|
||||
if (partRoles == null || partRoles.isEmpty()) {
|
||||
products = productRepository.findByPlatform(platform);
|
||||
products = productRepository.findByPlatformWithBrand(platform);
|
||||
} 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()) {
|
||||
long took = System.currentTimeMillis() - started;
|
||||
System.out.println("getGunbuilderProducts: 0 products in " + took + " ms");
|
||||
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()
|
||||
.map(Product::getId)
|
||||
.toList();
|
||||
|
||||
List<ProductOffer> allOffers =
|
||||
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()
|
||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||
|
||||
// 3) Map to DTOs with price and buyUrl
|
||||
return products.stream()
|
||||
long tMapStart = System.currentTimeMillis();
|
||||
List<ProductSummaryDto> result = products.stream()
|
||||
.map(p -> {
|
||||
List<ProductOffer> offersForProduct =
|
||||
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||
@@ -71,25 +91,36 @@ public class ProductController {
|
||||
return ProductMapper.toSummary(p, price, buyUrl);
|
||||
})
|
||||
.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")
|
||||
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
||||
|
||||
return offers.stream()
|
||||
.map(offer -> {
|
||||
ProductOfferDto dto = new ProductOfferDto();
|
||||
dto.setId(offer.getId().toString());
|
||||
dto.setMerchantName(offer.getMerchant().getName());
|
||||
dto.setPrice(offer.getEffectivePrice());
|
||||
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||
dto.setBuyUrl(offer.getBuyUrl());
|
||||
dto.setLastUpdated(offer.getLastSeenAt());
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
.map(offer -> {
|
||||
ProductOfferDto dto = new ProductOfferDto();
|
||||
dto.setId(offer.getId().toString());
|
||||
dto.setMerchantName(offer.getMerchant().getName());
|
||||
dto.setPrice(offer.getEffectivePrice());
|
||||
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||
dto.setBuyUrl(offer.getBuyUrl());
|
||||
dto.setLastUpdated(offer.getLastSeenAt());
|
||||
return dto;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -1,298 +1,140 @@
|
||||
package group.goforward.ballistic.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
@Column(name = "id", nullable = false, length = 21)
|
||||
private String id;
|
||||
|
||||
@Column(name = "name", length = Integer.MAX_VALUE)
|
||||
private String name;
|
||||
|
||||
@Column(name = "username", length = 50)
|
||||
private String username;
|
||||
|
||||
@Column(name = "email", nullable = false)
|
||||
private String email;
|
||||
|
||||
@Column(name = "first_name", length = 50)
|
||||
private String firstName;
|
||||
|
||||
@Column(name = "last_name", length = 50)
|
||||
private String lastName;
|
||||
|
||||
@Column(name = "full_name", length = 50)
|
||||
private String fullName;
|
||||
|
||||
@Column(name = "profile_picture")
|
||||
private String profilePicture;
|
||||
|
||||
@Column(name = "image", length = Integer.MAX_VALUE)
|
||||
private String image;
|
||||
|
||||
@Column(name = "date_of_birth")
|
||||
private LocalDate dateOfBirth;
|
||||
|
||||
@Column(name = "phone_number", length = 20)
|
||||
private String phoneNumber;
|
||||
|
||||
@ColumnDefault("CURRENT_TIMESTAMP")
|
||||
@Column(name = "created_at")
|
||||
private Instant createdAt;
|
||||
|
||||
@ColumnDefault("CURRENT_TIMESTAMP")
|
||||
@Column(name = "updated_at")
|
||||
private Instant updatedAt;
|
||||
|
||||
@ColumnDefault("false")
|
||||
@Column(name = "is_admin")
|
||||
private Boolean isAdmin;
|
||||
|
||||
@Column(name = "last_login")
|
||||
private Instant lastLogin;
|
||||
|
||||
@ColumnDefault("false")
|
||||
@Column(name = "email_verified", nullable = false)
|
||||
private Boolean emailVerified = false;
|
||||
|
||||
@ColumnDefault("'public'")
|
||||
@Column(name = "build_privacy_setting", length = Integer.MAX_VALUE)
|
||||
private String buildPrivacySetting;
|
||||
|
||||
@ColumnDefault("gen_random_uuid()")
|
||||
@Column(name = "uuid")
|
||||
private UUID uuid;
|
||||
|
||||
@Column(name = "discord_id")
|
||||
private String discordId;
|
||||
|
||||
@Column(name = "hashed_password")
|
||||
private String hashedPassword;
|
||||
|
||||
@Column(name = "avatar")
|
||||
private String avatar;
|
||||
|
||||
@Column(name = "stripe_subscription_id", length = 191)
|
||||
private String stripeSubscriptionId;
|
||||
|
||||
@Column(name = "stripe_price_id", length = 191)
|
||||
private String stripePriceId;
|
||||
|
||||
@Column(name = "stripe_customer_id", length = 191)
|
||||
private String stripeCustomerId;
|
||||
|
||||
@Column(name = "stripe_current_period_end")
|
||||
private Instant stripeCurrentPeriodEnd;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public void setUsername(String username) {
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getFirstName() {
|
||||
return firstName;
|
||||
}
|
||||
|
||||
public void setFirstName(String firstName) {
|
||||
this.firstName = firstName;
|
||||
}
|
||||
|
||||
public String getLastName() {
|
||||
return lastName;
|
||||
}
|
||||
|
||||
public void setLastName(String lastName) {
|
||||
this.lastName = lastName;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getProfilePicture() {
|
||||
return profilePicture;
|
||||
}
|
||||
|
||||
public void setProfilePicture(String profilePicture) {
|
||||
this.profilePicture = profilePicture;
|
||||
}
|
||||
|
||||
public String getImage() {
|
||||
return image;
|
||||
}
|
||||
|
||||
public void setImage(String image) {
|
||||
this.image = image;
|
||||
}
|
||||
|
||||
public LocalDate getDateOfBirth() {
|
||||
return dateOfBirth;
|
||||
}
|
||||
|
||||
public void setDateOfBirth(LocalDate dateOfBirth) {
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
|
||||
public String getPhoneNumber() {
|
||||
return phoneNumber;
|
||||
}
|
||||
|
||||
public void setPhoneNumber(String phoneNumber) {
|
||||
this.phoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(Instant createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public Instant getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(Instant updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public Boolean getIsAdmin() {
|
||||
return isAdmin;
|
||||
}
|
||||
|
||||
public void setIsAdmin(Boolean isAdmin) {
|
||||
this.isAdmin = isAdmin;
|
||||
}
|
||||
|
||||
public Instant getLastLogin() {
|
||||
return lastLogin;
|
||||
}
|
||||
|
||||
public void setLastLogin(Instant lastLogin) {
|
||||
this.lastLogin = lastLogin;
|
||||
}
|
||||
|
||||
public Boolean getEmailVerified() {
|
||||
return emailVerified;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
package group.goforward.ballistic.model;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User {
|
||||
@Id
|
||||
@NotNull
|
||||
@Column(name = "id", nullable = false)
|
||||
private Integer id;
|
||||
|
||||
@NotNull
|
||||
@ColumnDefault("gen_random_uuid()")
|
||||
@Column(name = "uuid", nullable = false)
|
||||
private UUID uuid;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
|
||||
private String email;
|
||||
|
||||
@NotNull
|
||||
@Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(name = "display_name", length = Integer.MAX_VALUE)
|
||||
private String displayName;
|
||||
|
||||
@NotNull
|
||||
@ColumnDefault("'USER'")
|
||||
@Column(name = "role", nullable = false, length = Integer.MAX_VALUE)
|
||||
private String role;
|
||||
|
||||
@NotNull
|
||||
@ColumnDefault("true")
|
||||
@Column(name = "is_active", nullable = false)
|
||||
private Boolean isActive = false;
|
||||
|
||||
@NotNull
|
||||
@ColumnDefault("now()")
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private OffsetDateTime createdAt;
|
||||
|
||||
@NotNull
|
||||
@ColumnDefault("now()")
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private OffsetDateTime updatedAt;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private OffsetDateTime deletedAt;
|
||||
|
||||
public Integer getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
public void setUuid(UUID uuid) {
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public Boolean getIsActive() {
|
||||
return isActive;
|
||||
}
|
||||
|
||||
public void setIsActive(Boolean isActive) {
|
||||
this.isActive = isActive;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public OffsetDateTime getDeletedAt() {
|
||||
return deletedAt;
|
||||
}
|
||||
|
||||
public void setDeletedAt(OffsetDateTime deletedAt) {
|
||||
this.deletedAt = deletedAt;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package group.goforward.ballistic.repos;
|
||||
import group.goforward.ballistic.model.Product;
|
||||
import group.goforward.ballistic.model.Brand;
|
||||
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;
|
||||
@@ -24,4 +26,28 @@ public interface ProductRepository extends JpaRepository<Product, Integer> {
|
||||
|
||||
// Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.)
|
||||
List<Product> findByPlatformAndPartRoleIn(String platform, Collection<String> partRoles);
|
||||
|
||||
// ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ----------
|
||||
|
||||
@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 :partRoles
|
||||
AND p.deletedAt IS NULL
|
||||
""")
|
||||
List<Product> findByPlatformAndPartRoleInWithBrand(
|
||||
@Param("platform") String platform,
|
||||
@Param("partRoles") Collection<String> partRoles
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -11,12 +11,15 @@ import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
|
||||
import group.goforward.ballistic.imports.MerchantFeedRow;
|
||||
import group.goforward.ballistic.services.MerchantFeedImportService;
|
||||
import org.apache.commons.csv.CSVFormat;
|
||||
import org.apache.commons.csv.CSVParser;
|
||||
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.Merchant;
|
||||
@@ -36,6 +39,7 @@ import java.time.OffsetDateTime;
|
||||
@Service
|
||||
@Transactional
|
||||
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
||||
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
||||
|
||||
private final MerchantRepository merchantRepository;
|
||||
private final BrandRepository brandRepository;
|
||||
@@ -56,27 +60,22 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
}
|
||||
|
||||
@Override
|
||||
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||
public void importMerchantFeed(Integer merchantId) {
|
||||
System.out.println("IMPORT >>> importMerchantFeed(" + merchantId + ")");
|
||||
log.info("Starting full import for merchantId={}", merchantId);
|
||||
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||
|
||||
// Read all rows from the merchant feed
|
||||
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) {
|
||||
// Resolve brand from the row (fallback to "Aero Precision" or whatever you want as default)
|
||||
Brand brand = resolveBrand(row);
|
||||
Product p = upsertProduct(merchant, brand, row);
|
||||
|
||||
System.out.println("IMPORT >>> upserted product id=" + p.getId()
|
||||
+ ", name=" + p.getName()
|
||||
+ ", slug=" + p.getSlug()
|
||||
+ ", platform=" + p.getPlatform()
|
||||
+ ", partRole=" + p.getPartRole()
|
||||
+ ", merchant=" + merchant.getName());
|
||||
log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}",
|
||||
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,9 +84,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
|
||||
System.out.println("IMPORT >>> upsertProduct brand=" + brand.getName()
|
||||
+ ", sku=" + row.sku()
|
||||
+ ", productName=" + row.productName());
|
||||
log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName());
|
||||
|
||||
String mpn = trimOrNull(row.manufacturerId());
|
||||
String upc = trimOrNull(row.sku()); // placeholder until real UPC field
|
||||
@@ -109,9 +106,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
p.setBrand(brand);
|
||||
} else {
|
||||
if (candidates.size() > 1) {
|
||||
System.out.println("IMPORT !!! WARNING: multiple existing products found for brand="
|
||||
+ brand.getName() + ", mpn=" + mpn + ", upc=" + upc
|
||||
+ ". Using the first match (id=" + candidates.get(0).getId() + ")");
|
||||
log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}",
|
||||
brand.getName(), mpn, upc, candidates.get(0).getId());
|
||||
}
|
||||
p = candidates.get(0);
|
||||
}
|
||||
@@ -127,10 +123,10 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
return saved;
|
||||
}
|
||||
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<>();
|
||||
|
||||
|
||||
try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://"))
|
||||
? new InputStreamReader(new URL(feedUrl).openStream(), 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()
|
||||
.withTrim()
|
||||
.parse(reader)) {
|
||||
|
||||
|
||||
// capture header names from the CSV
|
||||
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
|
||||
|
||||
|
||||
for (CSVRecord rec : parser) {
|
||||
Map<String, String> row = new HashMap<>();
|
||||
for (String header : headers) {
|
||||
@@ -153,8 +149,8 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
} catch (Exception 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;
|
||||
}
|
||||
|
||||
@@ -255,7 +251,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
String avantlinkProductId = trimOrNull(row.sku());
|
||||
if (avantlinkProductId == null) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -358,11 +354,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
|
||||
Map<String, Integer> headerMap = parser.getHeaderMap();
|
||||
if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) {
|
||||
System.out.println(
|
||||
"IMPORT >>> detected delimiter '" +
|
||||
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
|
||||
"' for feed: " + feedUrl
|
||||
);
|
||||
log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl);
|
||||
|
||||
return CSVFormat.DEFAULT.builder()
|
||||
.setDelimiter(delimiter)
|
||||
@@ -372,16 +364,11 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
.setTrim(true)
|
||||
.build();
|
||||
} else if (headerMap != null) {
|
||||
System.out.println(
|
||||
"IMPORT !!! delimiter '" +
|
||||
(delimiter == '\t' ? "\\t" : String.valueOf(delimiter)) +
|
||||
"' produced headers: " + headerMap.keySet()
|
||||
);
|
||||
log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
lastException = ex;
|
||||
System.out.println("IMPORT !!! error probing delimiter '" + delimiter +
|
||||
"' for " + feedUrl + ": " + ex.getMessage());
|
||||
log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -398,7 +385,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
}
|
||||
|
||||
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<>();
|
||||
|
||||
@@ -409,7 +396,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
try (Reader reader = openFeedReader(feedUrl);
|
||||
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) {
|
||||
MerchantFeedRow row = new MerchantFeedRow(
|
||||
@@ -447,7 +434,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
+ 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;
|
||||
}
|
||||
|
||||
@@ -474,7 +461,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
try {
|
||||
return new BigDecimal(trimmed);
|
||||
} catch (NumberFormatException ex) {
|
||||
System.out.println("IMPORT !!! bad BigDecimal value: '" + raw + "', skipping");
|
||||
log.debug("Skipping invalid numeric value '{}'", raw);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -495,8 +482,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
try {
|
||||
return rec.get(header);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
System.out.println("IMPORT !!! short record #" + rec.getRecordNumber()
|
||||
+ " missing column '" + header + "', treating as null");
|
||||
log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -593,32 +579,35 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||
public void syncOffersOnly(Integer merchantId) {
|
||||
log.info("Starting offers-only sync for merchantId={}", merchantId);
|
||||
Merchant merchant = merchantRepository.findById(merchantId)
|
||||
.orElseThrow(() -> new RuntimeException("Merchant not found"));
|
||||
|
||||
|
||||
if (Boolean.FALSE.equals(merchant.getIsActive())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use offerFeedUrl if present, else fall back to feedUrl
|
||||
|
||||
String feedUrl = merchant.getOfferFeedUrl() != null
|
||||
? merchant.getOfferFeedUrl()
|
||||
: merchant.getFeedUrl();
|
||||
|
||||
|
||||
if (feedUrl == null) {
|
||||
throw new RuntimeException("No offer feed URL configured for merchant " + merchantId);
|
||||
}
|
||||
|
||||
|
||||
List<Map<String, String>> rows = fetchFeedRows(feedUrl);
|
||||
|
||||
|
||||
for (Map<String, String> row : rows) {
|
||||
upsertOfferOnlyFromRow(merchant, row);
|
||||
}
|
||||
|
||||
|
||||
merchant.setLastOfferSyncAt(OffsetDateTime.now());
|
||||
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) {
|
||||
// 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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user