Compare commits

..

3 Commits

Author SHA1 Message Date
7e1b33efdf added java caching and optimized controller queries 2025-12-02 20:19:56 -05:00
009e512a66 docker... 2025-12-02 17:23:32 -05:00
9fabf30406 getting ready for docker deployment 2025-12-02 17:18:26 -05:00
14 changed files with 494 additions and 366 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.

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

View File

@@ -5,8 +5,10 @@ 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.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication @SpringBootApplication
@EnableCaching
@ComponentScan("group.goforward.ballistic.controllers") @ComponentScan("group.goforward.ballistic.controllers")
@ComponentScan("group.goforward.ballistic.repos") @ComponentScan("group.goforward.ballistic.repos")
@ComponentScan("group.goforward.ballistic.services") @ComponentScan("group.goforward.ballistic.services")

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,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,6 +91,17 @@ 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")
@@ -78,18 +109,18 @@ public class ProductController {
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

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

@@ -4,233 +4,67 @@ import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.ColumnDefault;
import java.time.Instant; import java.time.OffsetDateTime;
import java.time.LocalDate;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@Table(name = "users") @Table(name = "users")
public class User { public class User {
@Id @Id
@Column(name = "id", nullable = false, length = 21) @NotNull
private String id; @Column(name = "id", nullable = false)
private Integer 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;
@NotNull
@ColumnDefault("gen_random_uuid()") @ColumnDefault("gen_random_uuid()")
@Column(name = "uuid") @Column(name = "uuid", nullable = false)
private UUID uuid; private UUID uuid;
@Column(name = "discord_id") @NotNull
private String discordId; @Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
private String email;
@Column(name = "hashed_password") @NotNull
private String hashedPassword; @Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
private String passwordHash;
@Column(name = "avatar") @Column(name = "display_name", length = Integer.MAX_VALUE)
private String avatar; private String displayName;
@Column(name = "stripe_subscription_id", length = 191) @NotNull
private String stripeSubscriptionId; @ColumnDefault("'USER'")
@Column(name = "role", nullable = false, length = Integer.MAX_VALUE)
private String role;
@Column(name = "stripe_price_id", length = 191) @NotNull
private String stripePriceId; @ColumnDefault("true")
@Column(name = "is_active", nullable = false)
private Boolean isActive = false;
@Column(name = "stripe_customer_id", length = 191) @NotNull
private String stripeCustomerId; @ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "stripe_current_period_end") @NotNull
private Instant stripeCurrentPeriodEnd; @ColumnDefault("now()")
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
public String getId() { @Column(name = "deleted_at")
private OffsetDateTime deletedAt;
public Integer getId() {
return id; return id;
} }
public void setId(String id) { public void setId(Integer id) {
this.id = 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() { public UUID getUuid() {
return uuid; return uuid;
} }
@@ -239,60 +73,68 @@ public class User {
this.uuid = uuid; this.uuid = uuid;
} }
public String getDiscordId() { public String getEmail() {
return discordId; return email;
} }
public void setDiscordId(String discordId) { public void setEmail(String email) {
this.discordId = discordId; this.email = email;
} }
public String getHashedPassword() { public String getPasswordHash() {
return hashedPassword; return passwordHash;
} }
public void setHashedPassword(String hashedPassword) { public void setPasswordHash(String passwordHash) {
this.hashedPassword = hashedPassword; this.passwordHash = passwordHash;
} }
public String getAvatar() { public String getDisplayName() {
return avatar; return displayName;
} }
public void setAvatar(String avatar) { public void setDisplayName(String displayName) {
this.avatar = avatar; this.displayName = displayName;
} }
public String getStripeSubscriptionId() { public String getRole() {
return stripeSubscriptionId; return role;
} }
public void setStripeSubscriptionId(String stripeSubscriptionId) { public void setRole(String role) {
this.stripeSubscriptionId = stripeSubscriptionId; this.role = role;
} }
public String getStripePriceId() { public Boolean getIsActive() {
return stripePriceId; return isActive;
} }
public void setStripePriceId(String stripePriceId) { public void setIsActive(Boolean isActive) {
this.stripePriceId = stripePriceId; this.isActive = isActive;
} }
public String getStripeCustomerId() { public OffsetDateTime getCreatedAt() {
return stripeCustomerId; return createdAt;
} }
public void setStripeCustomerId(String stripeCustomerId) { public void setCreatedAt(OffsetDateTime createdAt) {
this.stripeCustomerId = stripeCustomerId; this.createdAt = createdAt;
} }
public Instant getStripeCurrentPeriodEnd() { public OffsetDateTime getUpdatedAt() {
return stripeCurrentPeriodEnd; return updatedAt;
} }
public void setStripeCurrentPeriodEnd(Instant stripeCurrentPeriodEnd) { public void setUpdatedAt(OffsetDateTime updatedAt) {
this.stripeCurrentPeriodEnd = stripeCurrentPeriodEnd; this.updatedAt = updatedAt;
}
public OffsetDateTime getDeletedAt() {
return deletedAt;
}
public void setDeletedAt(OffsetDateTime deletedAt) {
this.deletedAt = deletedAt;
} }
} }

View File

@@ -3,6 +3,8 @@ package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Product; import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Brand;
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.Optional;
import java.util.UUID; 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.) // Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.)
List<Product> findByPlatformAndPartRoleIn(String platform, Collection<String> partRoles); 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
);
} }

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,7 +123,7 @@ 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<>();
@@ -154,7 +150,7 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
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,7 +579,9 @@ 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"));
@@ -601,7 +589,6 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
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();
@@ -618,7 +605,9 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService
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

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