Compare commits

5 Commits

Author SHA1 Message Date
e986fa97ca running finally.. 2025-12-04 15:20:42 -05:00
9096ddd165 running finally.. 2025-12-04 15:06:29 -05:00
3d1501cc87 running finally.. 2025-12-04 14:43:07 -05:00
74a5c42e26 dynamic category mapping and updating from admin. 2025-12-03 21:50:00 -05:00
5e3f7d5044 expanded category grouping from db 2025-12-03 19:13:43 -05:00
85 changed files with 3525 additions and 2617 deletions

132
README.md
View File

@@ -1,67 +1,67 @@
# Ballistic Builder ( The Armory?) Backend # Ballistic Builder ( The Armory?) Backend
### Internal Engine for the Shadow System Armory? ### 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.
Its built for reliability, longevity, and clean extensibility — the kind of foundation you want when scaling from a small beta to a fully public platform. Its built for reliability, longevity, and clean extensibility — the kind of foundation you want when scaling from a small beta to a fully public platform.
--- ---
## What This Backend Does ## What This Backend Does
### **Merchant Feed Ingestion** ### **Merchant Feed Ingestion**
- Pulls AvantLink feeds (CSV or TSV) - Pulls AvantLink feeds (CSV or TSV)
- Automatically detects delimiters - Automatically detects delimiters
- Normalizes raw merchant fields - Normalizes raw merchant fields
- Creates or updates product records - Creates or updates product records
- Upserts price and stock offers - Upserts price and stock offers
- Tracks first-seen / last-seen timestamps - Tracks first-seen / last-seen timestamps
- Safely handles malformed or incomplete rows - Safely handles malformed or incomplete rows
- Ensures repeat imports never duplicate offers - Ensures repeat imports never duplicate offers
### **Category Mapping Engine** ### **Category Mapping Engine**
- Identifies every unique raw category coming from each merchant feed - Identifies every unique raw category coming from each merchant feed
- Exposes *unmapped* categories in the admin UI - Exposes *unmapped* categories in the admin UI
- Allows you to assign: - Allows you to assign:
- Part Role - Part Role
- Product Configuration (Stripped, Complete, Kit, etc.) - Product Configuration (Stripped, Complete, Kit, etc.)
- Applies mappings automatically on future imports - Applies mappings automatically on future imports
- Respects manual overrides such as `platform_locked` - Respects manual overrides such as `platform_locked`
### **Builder Support** ### **Builder Support**
The frontend Builder depends on this backend for: The frontend Builder depends on this backend for:
- Loading parts grouped by role - Loading parts grouped by role
- Offering compatible options - Offering compatible options
- Calculating build cost - Calculating build cost
- Comparing offers across merchants - Comparing offers across merchants
- Providing product metadata, imagery, and offer data - Providing product metadata, imagery, and offer data
**Future expansions include:** price history, compatibility engine, build exports, public build pages, multi-merchant aggregation, and automated anomaly detection. **Future expansions include:** price history, compatibility engine, build exports, public build pages, multi-merchant aggregation, and automated anomaly detection.
--- ---
## Tech Stack ## Tech Stack
- **Spring Boot 3.x** - **Spring Boot 3.x**
- **Java 17** - **Java 17**
- **PostgreSQL** - **PostgreSQL**
- **Hibernate (JPA)** - **Hibernate (JPA)**
- **HikariCP** - **HikariCP**
- **Apache Commons CSV** - **Apache Commons CSV**
- **Maven** - **Maven**
- **REST API** - **REST API**
--- ---
## Local Development ## Local Development
### Requirements ### Requirements
- Java 17 or newer - Java 17 or newer
- PostgreSQL running locally - PostgreSQL running locally
- Port 8080 open (default backend port) - Port 8080 open (default backend port)
### Run Development Server ### Run Development Server
```bash ```bash
./mvnw spring-boot:run ./mvnw spring-boot:run

View File

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

View File

@@ -1,34 +0,0 @@
# Stage 1: Build the application (The Build Stage)
# Use a Java SDK image with Maven pre-installed
FROM maven:3.9-jdk-17-slim AS build
# Set the working directory inside the container
WORKDIR /app
# Copy the Maven project files (pom.xml) first to leverage Docker layer caching
COPY pom.xml .
# Copy the source code
COPY src ./src
# Build the Spring Boot application, skipping tests to speed up the Docker build
# This creates the executable JAR file in the 'target' directory
RUN mvn clean package -DskipTests
# Stage 2: Create the final lightweight image (The Runtime Stage)
# Use a smaller Java Runtime Environment (JRE) image for a smaller footprint
FROM openjdk:17-jre-slim
# Set the working directory in the final image
WORKDIR /app
# Copy the built JAR file from the 'build' stage into the final image
# The JAR file is typically named 'target/<your-app-name>-<version>.jar'
# You may need to adjust the name if you have a non-standard pom.xml
COPY --from=build /app/target/*.jar app.jar
# Expose the default Spring Boot port
EXPOSE 8080
# Define the command to run the application
ENTRYPOINT ["java", "-jar", "app.jar"]

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

@@ -1,43 +1,63 @@
version: '3.8' version: '3.8'
services: services:
# --- 1. Spring API Service (Backend) --- # --- 1. Spring API Service (Backend) ---
ss_builder-api: spring-api:
build: build:
context: ./backend # Path to your Spring project's root folder context: ./backend # Path to your Spring project's root folder
dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend
container_name: ss_builder-api container_name: spring-api
ports: ports:
- "8080:8080" # Map host port 8080 to container port 8080 - "8080:8080" # Map host port 8080 to container port 8080
environment: environment:
# These environment variables link the API to the database service defined below # These environment variables link the API to the database service defined below
- SPRING_DATASOURCE_URL=jdbc:postgresql://r710.dev.gofwd.group:5433/ss_builder - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/mydatabase
- SPRING_DATASOURCE_USERNAME=dba - SPRING_DATASOURCE_USERNAME=myuser
- SPRING_DATASOURCE_PASSWORD=!@#Qwerty - SPRING_DATASOURCE_PASSWORD=mypassword
networks: depends_on:
- app-network - db
networks:
# --- 2. Next.js App Service (Frontend) --- - app-network
nextjs-app:
build: # --- 2. Next.js App Service (Frontend) ---
context: ./frontend # Path to your Next.js project's root folder nextjs-app:
dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend build:
container_name: ss_builder-app context: ./frontend # Path to your Next.js project's root folder
ports: dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend
- "3000:3000" # Map host port 3000 to container port 3000 container_name: nextjs-app
environment: ports:
# This variable is crucial: Next.js needs the URL for the Spring API - "3000:3000" # Map host port 3000 to container port 3000
# Use the Docker internal service name 'spring-api' and its port 8080 environment:
- NEXT_PUBLIC_API_URL=http://ss_builder-api:8080 # This variable is crucial: Next.js needs the URL for the Spring API
# For local testing, you might need the host IP for Next.js to call back # Use the Docker internal service name 'spring-api' and its port 8080
# - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080 - NEXT_PUBLIC_API_URL=http://spring-api:8080
depends_on: # For local testing, you might need the host IP for Next.js to call back
- ss_builder-api # - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080
networks: depends_on:
- app-network - spring-api
networks:
- app-network
# --- Docker Network for Inter-Container Communication ---
networks: # --- 3. PostgreSQL Database Service (Example Dependency) ---
app-network: 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 driver: bridge

View File

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

View File

@@ -1,213 +1,213 @@
# Ballistic Import Pipeline # Ballistic Import Pipeline
A high-level overview of how merchant data flows through the Spring ETL system. A high-level overview of how merchant data flows through the Spring ETL system.
--- ---
## Purpose ## Purpose
This document explains how the Ballistic backend: This document explains how the Ballistic backend:
1. Fetches merchant product feeds (CSV/TSV) 1. Fetches merchant product feeds (CSV/TSV)
2. Normalizes raw data into structured entities 2. Normalizes raw data into structured entities
3. Updates products and offers in an idempotent way 3. Updates products and offers in an idempotent way
4. Supports two sync modes: 4. Supports two sync modes:
- Full Import - Full Import
- Offer-Only Sync - Offer-Only Sync
--- ---
# 1. High-Level Flow # 1. High-Level Flow
## ASCII Diagram ## ASCII Diagram
``` ```
┌──────────────────────────┐ ┌──────────────────────────┐
│ /admin/imports/{id} │ │ /admin/imports/{id} │
│ (Full Import Trigger) │ │ (Full Import Trigger) │
└─────────────┬────────────┘ └─────────────┬────────────┘
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ importMerchantFeed(merchantId)│ │ importMerchantFeed(merchantId)│
└─────────────┬────────────────┘ └─────────────┬────────────────┘
┌────────────────────────────────────────────────────────┐ ┌────────────────────────────────────────────────────────┐
│ readFeedRowsForMerchant() │ │ readFeedRowsForMerchant() │
│ - auto-detect delimiter │ │ - auto-detect delimiter │
│ - parse CSV/TSV → MerchantFeedRow objects │ │ - parse CSV/TSV → MerchantFeedRow objects │
└─────────────────┬──────────────────────────────────────┘ └─────────────────┬──────────────────────────────────────┘
│ List<MerchantFeedRow> │ List<MerchantFeedRow>
┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐
│ For each MerchantFeedRow row: │ │ For each MerchantFeedRow row: │
│ resolveBrand() │ │ resolveBrand() │
│ upsertProduct() │ │ upsertProduct() │
│ - find existing via brand+mpn/upc │ │ - find existing via brand+mpn/upc │
│ - update fields (mapped partRole) │ │ - update fields (mapped partRole) │
│ upsertOfferFromRow() │ │ upsertOfferFromRow() │
└──────────────────────────────────────┘ └──────────────────────────────────────┘
``` ```
--- ---
# 2. Full Import Explained # 2. Full Import Explained
Triggered by: Triggered by:
``` ```
POST /admin/imports/{merchantId} POST /admin/imports/{merchantId}
``` ```
### Step 1 — Load merchant ### Step 1 — Load merchant
Using `merchantRepository.findById()`. Using `merchantRepository.findById()`.
### Step 2 — Parse feed rows ### Step 2 — Parse feed rows
`readFeedRowsForMerchant()`: `readFeedRowsForMerchant()`:
- Auto-detects delimiter (`\t`, `,`, `;`) - Auto-detects delimiter (`\t`, `,`, `;`)
- Validates required headers - Validates required headers
- Parses each row into `MerchantFeedRow` - Parses each row into `MerchantFeedRow`
### Step 3 — Process each row ### Step 3 — Process each row
For each parsed row: For each parsed row:
#### a. resolveBrand() #### a. resolveBrand()
- Finds or creates brand - Finds or creates brand
- Defaults to “Aero Precision” if missing - Defaults to “Aero Precision” if missing
#### b. upsertProduct() #### b. upsertProduct()
Dedupes by: Dedupes by:
1. Brand + MPN 1. Brand + MPN
2. Brand + UPC (currently SKU placeholder) 2. Brand + UPC (currently SKU placeholder)
If no match → create new product. If no match → create new product.
Then applies: Then applies:
- Name + slug - Name + slug
- Descriptions - Descriptions
- Images - Images
- MPN/identifiers - MPN/identifiers
- Platform inference - Platform inference
- Category mapping - Category mapping
- Part role inference - Part role inference
#### c. upsertOfferFromRow() #### c. upsertOfferFromRow()
Creates or updates a ProductOffer: Creates or updates a ProductOffer:
- Prices - Prices
- Stock - Stock
- Buy URL - Buy URL
- lastSeenAt - lastSeenAt
- firstSeenAt when newly created - firstSeenAt when newly created
Idempotent — does not duplicate offers. Idempotent — does not duplicate offers.
--- ---
# 3. Offer-Only Sync # 3. Offer-Only Sync
Triggered by: Triggered by:
``` ```
POST /admin/imports/{merchantId}/offers-only POST /admin/imports/{merchantId}/offers-only
``` ```
Does NOT: Does NOT:
- Create products - Create products
- Update product fields - Update product fields
It only updates: It only updates:
- price - price
- originalPrice - originalPrice
- inStock - inStock
- buyUrl - buyUrl
- lastSeenAt - lastSeenAt
If the offer does not exist, it is skipped. If the offer does not exist, it is skipped.
--- ---
# 4. Auto-Detecting CSV/TSV Parser # 4. Auto-Detecting CSV/TSV Parser
The parser: The parser:
- Attempts multiple delimiters - Attempts multiple delimiters
- Validates headers - Validates headers
- Handles malformed or short rows - Handles malformed or short rows
- Never throws on missing columns - Never throws on missing columns
- Returns clean MerchantFeedRow objects - Returns clean MerchantFeedRow objects
Designed for messy merchant feeds. Designed for messy merchant feeds.
--- ---
# 5. Entities Updated During Import # 5. Entities Updated During Import
### Product ### Product
- name - name
- slug - slug
- short/long description - short/long description
- main image - main image
- mpn - mpn
- upc (future) - upc (future)
- platform - platform
- rawCategoryKey - rawCategoryKey
- partRole - partRole
### ProductOffer ### ProductOffer
- merchant - merchant
- product - product
- avantlinkProductId (SKU placeholder) - avantlinkProductId (SKU placeholder)
- price - price
- originalPrice - originalPrice
- inStock - inStock
- buyUrl - buyUrl
- lastSeenAt - lastSeenAt
- firstSeenAt - firstSeenAt
### Merchant ### Merchant
- lastFullImportAt - lastFullImportAt
- lastOfferSyncAt - lastOfferSyncAt
--- ---
# 6. Extension Points # 6. Extension Points
You can extend the import pipeline in these areas: You can extend the import pipeline in these areas:
- Add per-merchant column mapping - Add per-merchant column mapping
- Add true UPC parsing - Add true UPC parsing
- Support multi-platform parts - Support multi-platform parts
- Improve partRole inference - Improve partRole inference
- Implement global deduplication across merchants - Implement global deduplication across merchants
--- ---
# 7. Quick Reference: Main Methods # 7. Quick Reference: Main Methods
| Method | Purpose | | Method | Purpose |
|--------|---------| |--------|---------|
| importMerchantFeed | Full product + offer import | | importMerchantFeed | Full product + offer import |
| readFeedRowsForMerchant | Detect delimiter + parse feed | | readFeedRowsForMerchant | Detect delimiter + parse feed |
| resolveBrand | Normalize brand names | | resolveBrand | Normalize brand names |
| upsertProduct | Idempotent product write | | upsertProduct | Idempotent product write |
| updateProductFromRow | Apply product fields | | updateProductFromRow | Apply product fields |
| upsertOfferFromRow | Idempotent offer write | | upsertOfferFromRow | Idempotent offer write |
| syncOffersOnly | Offer-only sync | | syncOffersOnly | Offer-only sync |
| upsertOfferOnlyFromRow | Update existing offers | | upsertOfferOnlyFromRow | Update existing offers |
| detectCsvFormat | Auto-detect delimiter | | detectCsvFormat | Auto-detect delimiter |
| fetchFeedRows | Simpler parser for offers | | fetchFeedRows | Simpler parser for offers |
--- ---
# 8. Summary # 8. Summary
The Ballistic importer is: The Ballistic importer is:
- Robust against bad data - Robust against bad data
- Idempotent and safe - Idempotent and safe
- Flexible for multiple merchants - Flexible for multiple merchants
- Extensible for long-term scaling - Extensible for long-term scaling
This pipeline powers the product catalog and offer data for the Ballistic ecosystem. This pipeline powers the product catalog and offer data for the Ballistic ecosystem.

View File

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

View File

@@ -30,9 +30,14 @@ public class CorsConfig {
"https://localhost:8080", "https://localhost:8080",
"http://localhost:3000", "http://localhost:3000",
"https://localhost:3000", "https://localhost:3000",
"https://localhost:3000/gunbuilder", "http://192.168.11.210:8070",
"http://localhost:3000/gunbuilder" "https://192.168.11.210:8070",
)); "http://citysites.gofwd.group",
"https://citysites.gofwd.group",
"http://citysites.gofwd.group:8070",
"https://citysites.gofwd.group:8070"
));
// Allow all headers // Allow all headers
config.addAllowedHeader("*"); config.addAllowedHeader("*");

View File

@@ -24,14 +24,19 @@ public class SecurityConfig {
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS) sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
) )
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
// Auth endpoints always open // Auth endpoints always open
.requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/auth/**").permitAll()
// Swagger / docs // Swagger / docs
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
// Health // Health
.requestMatchers("/actuator/health", "/actuator/info").permitAll() .requestMatchers("/actuator/health", "/actuator/info").permitAll()
// Public product endpoints // Public product endpoints
.requestMatchers("/api/products/gunbuilder/**").permitAll() .requestMatchers("/api/products/gunbuilder/**").permitAll()
// Everything else (for now) also open we can tighten later // Everything else (for now) also open we can tighten later
.anyRequest().permitAll() .anyRequest().permitAll()
); );

View File

@@ -1,13 +1,13 @@
/** /**
* Provides the classes necessary for the Spring Configurations for the ballistic -Builder application. * Provides the classes necessary for the Spring Configurations for the ballistic -Builder application.
* This package includes Configurations for Spring-Boot application * This package includes Configurations for Spring-Boot application
* *
* *
* <p>The main entry point for managing the inventory is the * <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p> * {@link group.goforward.ballistic.BallisticApplication} class.</p>
* *
* @since 1.0 * @since 1.0
* @author Don Strawsburg * @author Don Strawsburg
* @version 1.1 * @version 1.1
*/ */
package group.goforward.ballistic.configuration; package group.goforward.ballistic.configuration;

View File

@@ -1,51 +0,0 @@
package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.model.State;
import group.goforward.ballistic.repos.BrandRepository;
import group.goforward.ballistic.services.BrandService;
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("/api/brands")
public class BrandController {
@Autowired
private BrandRepository repo;
@Autowired
private BrandService brandService;
//@Cacheable(value="getAllStates")
@GetMapping("/all")
public ResponseEntity<List<Brand>> getAllBrands() {
List<Brand> brand = repo.findAll();
return ResponseEntity.ok(brand);
}
@GetMapping("/{id}")
public ResponseEntity<Brand> getAllBrandsById(@PathVariable Integer id) {
return repo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/add")
public ResponseEntity<Brand> createbrand(@RequestBody Brand item) {
Brand created = brandService.save(item);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return brandService.findById(id)
.map(item -> {
brandService.deleteById(id);
return ResponseEntity.noContent().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
}

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

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

View File

@@ -1,39 +1,39 @@
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;
import group.goforward.ballistic.services.MerchantFeedImportService; import group.goforward.ballistic.services.MerchantFeedImportService;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/admin/imports") @RequestMapping("/admin/imports")
@CrossOrigin(origins = "http://localhost:3000") @CrossOrigin(origins = "http://localhost:3000")
public class ImportController { public class ImportController {
private final MerchantFeedImportService merchantFeedImportService; private final MerchantFeedImportService merchantFeedImportService;
public ImportController(MerchantFeedImportService merchantFeedImportService) { public ImportController(MerchantFeedImportService merchantFeedImportService) {
this.merchantFeedImportService = merchantFeedImportService; this.merchantFeedImportService = merchantFeedImportService;
} }
/** /**
* Full product + offer import for a merchant. * Full product + offer import for a merchant.
* *
* POST /admin/imports/{merchantId} * POST /admin/imports/{merchantId}
*/ */
@PostMapping("/{merchantId}") @PostMapping("/{merchantId}")
public ResponseEntity<Void> importMerchant(@PathVariable Integer merchantId) { public ResponseEntity<Void> importMerchant(@PathVariable Integer merchantId) {
merchantFeedImportService.importMerchantFeed(merchantId); merchantFeedImportService.importMerchantFeed(merchantId);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/** /**
* Offers-only sync (price/stock) for a merchant. * Offers-only sync (price/stock) for a merchant.
* *
* POST /admin/imports/{merchantId}/offers-only * POST /admin/imports/{merchantId}/offers-only
*/ */
@PostMapping("/{merchantId}/offers-only") @PostMapping("/{merchantId}/offers-only")
public ResponseEntity<Void> syncOffersOnly(@PathVariable Integer merchantId) { public ResponseEntity<Void> syncOffersOnly(@PathVariable Integer merchantId) {
merchantFeedImportService.syncOffersOnly(merchantId); merchantFeedImportService.syncOffersOnly(merchantId);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
} }

View File

@@ -1,63 +1,63 @@
// MerchantAdminController.java // MerchantAdminController.java
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;
import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.web.dto.MerchantAdminDto; import group.goforward.ballistic.web.dto.MerchantAdminDto;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
@RestController @RestController
@RequestMapping("/admin/merchants") @RequestMapping("/admin/merchants")
@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug @CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug
public class MerchantAdminController { public class MerchantAdminController {
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
public MerchantAdminController(MerchantRepository merchantRepository) { public MerchantAdminController(MerchantRepository merchantRepository) {
this.merchantRepository = merchantRepository; this.merchantRepository = merchantRepository;
} }
@GetMapping @GetMapping
public List<MerchantAdminDto> listMerchants() { public List<MerchantAdminDto> listMerchants() {
return merchantRepository.findAll().stream().map(this::toDto).toList(); return merchantRepository.findAll().stream().map(this::toDto).toList();
} }
@PutMapping("/{id}") @PutMapping("/{id}")
public MerchantAdminDto updateMerchant( public MerchantAdminDto updateMerchant(
@PathVariable Integer id, @PathVariable Integer id,
@RequestBody MerchantAdminDto payload @RequestBody MerchantAdminDto payload
) { ) {
Merchant merchant = merchantRepository.findById(id) Merchant merchant = merchantRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Merchant not found")); .orElseThrow(() -> new RuntimeException("Merchant not found"));
merchant.setFeedUrl(payload.getFeedUrl()); merchant.setFeedUrl(payload.getFeedUrl());
merchant.setOfferFeedUrl(payload.getOfferFeedUrl()); merchant.setOfferFeedUrl(payload.getOfferFeedUrl());
merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true); merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true);
// dont touch last* here; those are set by import jobs // dont touch last* here; those are set by import jobs
merchant = merchantRepository.save(merchant); merchant = merchantRepository.save(merchant);
return toDto(merchant); return toDto(merchant);
} }
private MerchantAdminDto toDto(Merchant m) { private MerchantAdminDto toDto(Merchant m) {
MerchantAdminDto dto = new MerchantAdminDto(); MerchantAdminDto dto = new MerchantAdminDto();
dto.setId(m.getId()); dto.setId(m.getId());
dto.setName(m.getName()); dto.setName(m.getName());
dto.setFeedUrl(m.getFeedUrl()); dto.setFeedUrl(m.getFeedUrl());
dto.setOfferFeedUrl(m.getOfferFeedUrl()); dto.setOfferFeedUrl(m.getOfferFeedUrl());
dto.setIsActive(m.getIsActive()); dto.setIsActive(m.getIsActive());
dto.setLastFullImportAt(m.getLastFullImportAt()); dto.setLastFullImportAt(m.getLastFullImportAt());
dto.setLastOfferSyncAt(m.getLastOfferSyncAt()); dto.setLastOfferSyncAt(m.getLastOfferSyncAt());
return dto; return dto;
} }
} }

View File

@@ -1,65 +1,65 @@
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping; import group.goforward.ballistic.model.MerchantCategoryMapping;
import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.MerchantRepository;
import group.goforward.ballistic.services.MerchantCategoryMappingService; import group.goforward.ballistic.services.MerchantCategoryMappingService;
import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto; import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto;
import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest; import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@RestController @RestController
@RequestMapping("/admin/merchant-category-mappings") @RequestMapping("/admin/merchant-category-mappings")
@CrossOrigin @CrossOrigin
public class MerchantCategoryMappingController { public class MerchantCategoryMappingController {
private final MerchantCategoryMappingService mappingService; private final MerchantCategoryMappingService mappingService;
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
public MerchantCategoryMappingController( public MerchantCategoryMappingController(
MerchantCategoryMappingService mappingService, MerchantCategoryMappingService mappingService,
MerchantRepository merchantRepository MerchantRepository merchantRepository
) { ) {
this.mappingService = mappingService; this.mappingService = mappingService;
this.merchantRepository = merchantRepository; this.merchantRepository = merchantRepository;
} }
@GetMapping @GetMapping
public List<MerchantCategoryMappingDto> listMappings( public List<MerchantCategoryMappingDto> listMappings(
@RequestParam("merchantId") Integer merchantId @RequestParam("merchantId") Integer merchantId
) { ) {
List<MerchantCategoryMapping> mappings = mappingService.findByMerchant(merchantId); List<MerchantCategoryMapping> mappings = mappingService.findByMerchant(merchantId);
return mappings.stream() return mappings.stream()
.map(this::toDto) .map(this::toDto)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@PostMapping @PostMapping
public MerchantCategoryMappingDto upsertMapping( public MerchantCategoryMappingDto upsertMapping(
@RequestBody UpsertMerchantCategoryMappingRequest request @RequestBody UpsertMerchantCategoryMappingRequest request
) { ) {
Merchant merchant = merchantRepository Merchant merchant = merchantRepository
.findById(request.getMerchantId()) .findById(request.getMerchantId())
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId())); .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId()));
MerchantCategoryMapping mapping = mappingService.upsertMapping( MerchantCategoryMapping mapping = mappingService.upsertMapping(
merchant, merchant,
request.getRawCategory(), request.getRawCategory(),
request.getMappedPartRole() request.getMappedPartRole()
); );
return toDto(mapping); return toDto(mapping);
} }
private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) { private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) {
MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto(); MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto();
dto.setId(mapping.getId()); dto.setId(mapping.getId());
dto.setMerchantId(mapping.getMerchant().getId()); dto.setMerchantId(mapping.getMerchant().getId());
dto.setMerchantName(mapping.getMerchant().getName()); dto.setMerchantName(mapping.getMerchant().getName());
dto.setRawCategory(mapping.getRawCategory()); dto.setRawCategory(mapping.getRawCategory());
dto.setMappedPartRole(mapping.getMappedPartRole()); dto.setMappedPartRole(mapping.getMappedPartRole());
return dto; return dto;
} }
} }

View File

@@ -1,23 +1,23 @@
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.repos.MerchantRepository; import group.goforward.ballistic.repos.MerchantRepository;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List; import java.util.List;
@RestController @RestController
public class MerchantDebugController { public class MerchantDebugController {
private final MerchantRepository merchantRepository; private final MerchantRepository merchantRepository;
public MerchantDebugController(MerchantRepository merchantRepository) { public MerchantDebugController(MerchantRepository merchantRepository) {
this.merchantRepository = merchantRepository; this.merchantRepository = merchantRepository;
} }
@GetMapping("/admin/debug/merchants") @GetMapping("/admin/debug/merchants")
public List<Merchant> listMerchants() { public List<Merchant> listMerchants() {
return merchantRepository.findAll(); return merchantRepository.findAll();
} }
} }

View File

@@ -1,13 +1,13 @@
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
public class PingController { public class PingController {
@GetMapping("/ping") @GetMapping("/ping")
public String ping() { public String ping() {
return "pong"; return "pong";
} }
} }

View File

@@ -1,137 +1,137 @@
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;
import group.goforward.ballistic.model.Product; import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.model.ProductOffer; import group.goforward.ballistic.model.ProductOffer;
import group.goforward.ballistic.repos.ProductOfferRepository; import group.goforward.ballistic.repos.ProductOfferRepository;
import group.goforward.ballistic.web.dto.ProductOfferDto; 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.cache.annotation.Cacheable;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@RequestMapping("/api/products") @RequestMapping("/api/products")
@CrossOrigin @CrossOrigin
public class ProductController { public class ProductController {
private final ProductRepository productRepository; private final ProductRepository productRepository;
private final ProductOfferRepository productOfferRepository; private final ProductOfferRepository productOfferRepository;
public ProductController( public ProductController(
ProductRepository productRepository, ProductRepository productRepository,
ProductOfferRepository productOfferRepository ProductOfferRepository productOfferRepository
) { ) {
this.productRepository = productRepository; this.productRepository = productRepository;
this.productOfferRepository = productOfferRepository; this.productOfferRepository = productOfferRepository;
} }
@GetMapping("/gunbuilder") @GetMapping("/gunbuilder")
@Cacheable( @Cacheable(
value = "gunbuilderProducts", value = "gunbuilderProducts",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())" 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
) { ) {
long started = System.currentTimeMillis(); long started = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: start, platform=" + platform + System.out.println("getGunbuilderProducts: start, platform=" + platform +
", partRoles=" + (partRoles == null ? "null" : partRoles)); ", partRoles=" + (partRoles == null ? "null" : partRoles));
// 1) Load products (with brand pre-fetched) // 1) Load products (with brand pre-fetched)
long tProductsStart = System.currentTimeMillis(); long tProductsStart = System.currentTimeMillis();
List<Product> products; List<Product> products;
if (partRoles == null || partRoles.isEmpty()) { if (partRoles == null || partRoles.isEmpty()) {
products = productRepository.findByPlatformWithBrand(platform); products = productRepository.findByPlatformWithBrand(platform);
} else { } else {
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles); products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
} }
long tProductsEnd = System.currentTimeMillis(); long tProductsEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded products: " + System.out.println("getGunbuilderProducts: loaded products: " +
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms"); products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
if (products.isEmpty()) { if (products.isEmpty()) {
long took = System.currentTimeMillis() - started; long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: 0 products in " + took + " ms"); System.out.println("getGunbuilderProducts: 0 products in " + took + " ms");
return List.of(); return List.of();
} }
// 2) Load offers for these product IDs // 2) Load offers for these product IDs
long tOffersStart = System.currentTimeMillis(); 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(); long tOffersEnd = System.currentTimeMillis();
System.out.println("getGunbuilderProducts: loaded offers: " + System.out.println("getGunbuilderProducts: loaded offers: " +
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms"); 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
long tMapStart = System.currentTimeMillis(); long tMapStart = System.currentTimeMillis();
List<ProductSummaryDto> result = products.stream() 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());
ProductOffer bestOffer = pickBestOffer(offersForProduct); ProductOffer bestOffer = pickBestOffer(offersForProduct);
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null; BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null; String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
return ProductMapper.toSummary(p, price, buyUrl); return ProductMapper.toSummary(p, price, buyUrl);
}) })
.toList(); .toList();
long tMapEnd = System.currentTimeMillis(); long tMapEnd = System.currentTimeMillis();
long took = System.currentTimeMillis() - started; long took = System.currentTimeMillis() - started;
System.out.println("getGunbuilderProducts: mapping to DTOs took " + System.out.println("getGunbuilderProducts: mapping to DTOs took " +
(tMapEnd - tMapStart) + " ms"); (tMapEnd - tMapStart) + " ms");
System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" + System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" +
"products=" + (tProductsEnd - tProductsStart) + " ms, " + "products=" + (tProductsEnd - tProductsStart) + " ms, " +
"offers=" + (tOffersEnd - tOffersStart) + " ms, " + "offers=" + (tOffersEnd - tOffersStart) + " ms, " +
"map=" + (tMapEnd - tMapStart) + " ms)"); "map=" + (tMapEnd - tMapStart) + " ms)");
return result; 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) {
if (offers == null || offers.isEmpty()) { if (offers == null || offers.isEmpty()) {
return null; return null;
} }
// Right now: lowest price wins, regardless of stock (we set inStock=true on import anyway) // Right now: lowest price wins, regardless of stock (we set inStock=true on import anyway)
return offers.stream() return offers.stream()
.filter(o -> o.getEffectivePrice() != null) .filter(o -> o.getEffectivePrice() != null)
.min(Comparator.comparing(ProductOffer::getEffectivePrice)) .min(Comparator.comparing(ProductOffer::getEffectivePrice))
.orElse(null); .orElse(null);
} }
} }

View File

@@ -5,7 +5,6 @@ import group.goforward.ballistic.model.State;
import group.goforward.ballistic.repos.StateRepository; import group.goforward.ballistic.repos.StateRepository;
import group.goforward.ballistic.services.StatesService; import group.goforward.ballistic.services.StatesService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -14,38 +13,44 @@ import java.util.List;
@RestController @RestController
@RequestMapping("/api/states") @RequestMapping()
public class StateController { public class StateController {
@Autowired @Autowired
private StateRepository repo; private StateRepository repo;
@Autowired @Autowired
private StatesService statesService; private StatesService statesService;
//@Cacheable(value="getAllStates")
@GetMapping("/all") @GetMapping("/api/getAllStates")
public ResponseEntity<List<State>> getAllStates() { public ResponseEntity<List<State>> getAllStates() {
List<State> state = repo.findAll(); List<State> state = repo.findAll();
return ResponseEntity.ok(state); return ResponseEntity.ok(state);
} }
@GetMapping("/{id}") @GetMapping("/api/getAllStatesTest")
public ApiResponse<List<State>> getAllStatesTest() {
List<State> state = repo.findAll();
return ApiResponse.success(state);
}
@GetMapping("/api/getAllStatesById/{id}")
public ResponseEntity<State> getAllStatesById(@PathVariable Integer id) { public ResponseEntity<State> getAllStatesById(@PathVariable Integer id) {
return repo.findById(id) return repo.findById(id)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@GetMapping("/byAbbrev/{abbreviation}") @GetMapping("/api/getAllStatesByAbbreviation/{abbreviation}")
public ResponseEntity<State> getAllStatesByAbbreviation(@PathVariable String abbreviation) { public ResponseEntity<State> getAllStatesByAbbreviation(@PathVariable String abbreviation) {
return repo.findByAbbreviation(abbreviation) return repo.findByAbbreviation(abbreviation)
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build()); .orElse(ResponseEntity.notFound().build());
} }
@PostMapping("/addState") @PostMapping("/api/addState")
public ResponseEntity<State> createState(@RequestBody State item) { public ResponseEntity<State> createState(@RequestBody State item) {
State created = statesService.save(item); State created = statesService.save(item);
return ResponseEntity.status(HttpStatus.CREATED).body(created); return ResponseEntity.status(HttpStatus.CREATED).body(created);
} }
@DeleteMapping("/deleteState/{id}") @DeleteMapping("/api/deleteState/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) { public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return statesService.findById(id) return statesService.findById(id)
.map(item -> { .map(item -> {

View File

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

@@ -1,13 +1,13 @@
/** /**
* Provides the classes necessary for the Spring Controllers for the ballistic -Builder application. * Provides the classes necessary for the Spring Controllers for the ballistic -Builder application.
* This package includes Controllers for Spring-Boot application * This package includes Controllers for Spring-Boot application
* *
* *
* <p>The main entry point for managing the inventory is the * <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p> * {@link group.goforward.ballistic.BallisticApplication} class.</p>
* *
* @since 1.0 * @since 1.0
* @author Don Strawsburg * @author Don Strawsburg
* @version 1.1 * @version 1.1
*/ */
package group.goforward.ballistic.controllers; package group.goforward.ballistic.controllers;

View File

@@ -1,30 +1,30 @@
package group.goforward.ballistic.imports; package group.goforward.ballistic.imports;
import java.math.BigDecimal; import java.math.BigDecimal;
public record MerchantFeedRow( public record MerchantFeedRow(
String sku, String sku,
String manufacturerId, String manufacturerId,
String brandName, String brandName,
String productName, String productName,
String longDescription, String longDescription,
String shortDescription, String shortDescription,
String department, String department,
String category, String category,
String subCategory, String subCategory,
String thumbUrl, String thumbUrl,
String imageUrl, String imageUrl,
String buyLink, String buyLink,
String keywords, String keywords,
String reviews, String reviews,
BigDecimal retailPrice, BigDecimal retailPrice,
BigDecimal salePrice, BigDecimal salePrice,
String brandPageLink, String brandPageLink,
String brandLogoImage, String brandLogoImage,
String productPageViewTracking, String productPageViewTracking,
String variantsXml, String variantsXml,
String mediumImageUrl, String mediumImageUrl,
String productContentWidget, String productContentWidget,
String googleCategorization, String googleCategorization,
String itemBasedCommission String itemBasedCommission
) {} ) {}

View File

@@ -1,17 +1,17 @@
package group.goforward.ballistic.imports.dto; package group.goforward.ballistic.imports.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
public record MerchantFeedRow( public record MerchantFeedRow(
String brandName, String brandName,
String productName, String productName,
String mpn, String mpn,
String upc, String upc,
String avantlinkProductId, String avantlinkProductId,
String sku, String sku,
String categoryPath, String categoryPath,
String buyUrl, String buyUrl,
BigDecimal price, BigDecimal price,
BigDecimal originalPrice, BigDecimal originalPrice,
boolean inStock boolean inStock
) {} ) {}

View File

@@ -1,13 +1,13 @@
/** /**
* Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application. * Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application.
* This package includes DTO for Spring-Boot application * This package includes DTO for Spring-Boot application
* *
* *
* <p>The main entry point for managing the inventory is the * <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p> * {@link group.goforward.ballistic.BallisticApplication} class.</p>
* *
* @since 1.0 * @since 1.0
* @author Sean Strawsburg * @author Sean Strawsburg
* @version 1.1 * @version 1.1
*/ */
package group.goforward.ballistic.imports.dto; package group.goforward.ballistic.imports.dto;

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,105 +1,105 @@
package group.goforward.ballistic.model; package group.goforward.ballistic.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import group.goforward.ballistic.model.ProductConfiguration; import group.goforward.ballistic.model.ProductConfiguration;
@Entity @Entity
@Table( @Table(
name = "merchant_category_mappings", name = "merchant_category_mappings",
uniqueConstraints = @UniqueConstraint( uniqueConstraints = @UniqueConstraint(
name = "uq_merchant_category", name = "uq_merchant_category",
columnNames = { "merchant_id", "raw_category" } columnNames = { "merchant_id", "raw_category" }
) )
) )
public class MerchantCategoryMapping { public class MerchantCategoryMapping {
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL
@Column(name = "id", nullable = false) @Column(name = "id", nullable = false)
private Integer id; private Integer id;
@ManyToOne(fetch = FetchType.LAZY, optional = false) @ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "merchant_id", nullable = false) @JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant; private Merchant merchant;
@Column(name = "raw_category", nullable = false, length = 512) @Column(name = "raw_category", nullable = false, length = 512)
private String rawCategory; private String rawCategory;
@Column(name = "mapped_part_role", length = 128) @Column(name = "mapped_part_role", length = 128)
private String mappedPartRole; // e.g. "upper-receiver", "barrel" private String mappedPartRole; // e.g. "upper-receiver", "barrel"
@Column(name = "mapped_configuration") @Column(name = "mapped_configuration")
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private ProductConfiguration mappedConfiguration; private ProductConfiguration mappedConfiguration;
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt = OffsetDateTime.now(); private OffsetDateTime createdAt = OffsetDateTime.now();
@Column(name = "updated_at", nullable = false) @Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt = OffsetDateTime.now(); private OffsetDateTime updatedAt = OffsetDateTime.now();
@PreUpdate @PreUpdate
public void onUpdate() { public void onUpdate() {
this.updatedAt = OffsetDateTime.now(); this.updatedAt = OffsetDateTime.now();
} }
// getters & setters // getters & setters
public Integer getId() { public Integer getId() {
return id; return id;
} }
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
public Merchant getMerchant() { public Merchant getMerchant() {
return merchant; return merchant;
} }
public void setMerchant(Merchant merchant) { public void setMerchant(Merchant merchant) {
this.merchant = merchant; this.merchant = merchant;
} }
public String getRawCategory() { public String getRawCategory() {
return rawCategory; return rawCategory;
} }
public void setRawCategory(String rawCategory) { public void setRawCategory(String rawCategory) {
this.rawCategory = rawCategory; this.rawCategory = rawCategory;
} }
public String getMappedPartRole() { public String getMappedPartRole() {
return mappedPartRole; return mappedPartRole;
} }
public void setMappedPartRole(String mappedPartRole) { public void setMappedPartRole(String mappedPartRole) {
this.mappedPartRole = mappedPartRole; this.mappedPartRole = mappedPartRole;
} }
public ProductConfiguration getMappedConfiguration() { public ProductConfiguration getMappedConfiguration() {
return mappedConfiguration; return mappedConfiguration;
} }
public void setMappedConfiguration(ProductConfiguration mappedConfiguration) { public void setMappedConfiguration(ProductConfiguration mappedConfiguration) {
this.mappedConfiguration = mappedConfiguration; this.mappedConfiguration = mappedConfiguration;
} }
public OffsetDateTime getCreatedAt() { public OffsetDateTime getCreatedAt() {
return createdAt; return createdAt;
} }
public void setCreatedAt(OffsetDateTime createdAt) { public void setCreatedAt(OffsetDateTime createdAt) {
this.createdAt = createdAt; this.createdAt = createdAt;
} }
public OffsetDateTime getUpdatedAt() { public OffsetDateTime getUpdatedAt() {
return updatedAt; return updatedAt;
} }
public void setUpdatedAt(OffsetDateTime updatedAt) { public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = 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

@@ -1,10 +1,10 @@
package group.goforward.ballistic.model; package group.goforward.ballistic.model;
public enum ProductConfiguration { public enum ProductConfiguration {
STRIPPED, // bare receiver / component STRIPPED, // bare receiver / component
ASSEMBLED, // built up but not fully complete ASSEMBLED, // built up but not fully complete
BARRELED, // upper + barrel + gas system, no BCG/CH BARRELED, // upper + barrel + gas system, no BCG/CH
COMPLETE, // full assembly ready to run COMPLETE, // full assembly ready to run
KIT, // collection of parts (LPK, trigger kits, etc.) KIT, // collection of parts (LPK, trigger kits, etc.)
OTHER // fallback / unknown OTHER // fallback / unknown
} }

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,9 +1,9 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Account; import group.goforward.ballistic.model.Account;
import org.springframework.data.jpa.repository.JpaRepository; 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,8 +1,8 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
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 java.util.Optional; import java.util.Optional;
public interface BrandRepository extends JpaRepository<Brand, Integer> { public interface BrandRepository extends JpaRepository<Brand, Integer> {
Optional<Brand> findByNameIgnoreCase(String name); Optional<Brand> findByNameIgnoreCase(String name);
} }

View File

@@ -1,12 +1,12 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.BuildsComponent; import group.goforward.ballistic.model.BuildsComponent;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface BuildItemRepository extends JpaRepository<BuildsComponent, Integer> { public interface BuildItemRepository extends JpaRepository<BuildsComponent, Integer> {
List<BuildsComponent> findByBuildId(Integer buildId); List<BuildsComponent> findByBuildId(Integer buildId);
Optional<BuildsComponent> findByUuid(UUID uuid); Optional<BuildsComponent> findByUuid(UUID uuid);
} }

View File

@@ -1,10 +1,10 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Build; import group.goforward.ballistic.model.Build;
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 BuildRepository extends JpaRepository<Build, Integer> { public interface BuildRepository extends JpaRepository<Build, Integer> {
Optional<Build> findByUuid(UUID uuid); Optional<Build> findByUuid(UUID 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 org.springframework.data.jpa.repository.JpaRepository; import group.goforward.ballistic.model.Merchant;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryMappingRepository extends JpaRepository<AffiliateCategoryMap, Integer> { import org.springframework.data.jpa.repository.Query;
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

@@ -1,7 +1,7 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.FeedImport; import group.goforward.ballistic.model.FeedImport;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface FeedImportRepository extends JpaRepository<FeedImport, Long> { public interface FeedImportRepository extends JpaRepository<FeedImport, Long> {
} }

View File

@@ -1,17 +1,17 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.MerchantCategoryMapping; import group.goforward.ballistic.model.MerchantCategoryMapping;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface MerchantCategoryMappingRepository public interface MerchantCategoryMappingRepository
extends JpaRepository<MerchantCategoryMapping, Integer> { extends JpaRepository<MerchantCategoryMapping, Integer> {
Optional<MerchantCategoryMapping> findByMerchantIdAndRawCategoryIgnoreCase( Optional<MerchantCategoryMapping> findByMerchantIdAndRawCategoryIgnoreCase(
Integer merchantId, Integer merchantId,
String rawCategory String rawCategory
); );
List<MerchantCategoryMapping> findByMerchantIdOrderByRawCategoryAsc(Integer merchantId); List<MerchantCategoryMapping> findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
} }

View File

@@ -1,11 +1,11 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
public interface MerchantRepository extends JpaRepository<Merchant, Integer> { public interface MerchantRepository extends JpaRepository<Merchant, Integer> {
Optional<Merchant> findByNameIgnoreCase(String name); Optional<Merchant> findByNameIgnoreCase(String name);
} }

View File

@@ -1,9 +1,14 @@
package group.goforward.ballistic.repos; 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.Optional;
import java.util.List;
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> { import java.util.Optional;
Optional<PartCategory> findBySlug(String slug);
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
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,7 +1,7 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.PriceHistory; import group.goforward.ballistic.model.PriceHistory;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface PriceHistoryRepository extends JpaRepository<PriceHistory, Long> { public interface PriceHistoryRepository extends JpaRepository<PriceHistory, Long> {
} }

View File

@@ -1,22 +1,22 @@
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;
import group.goforward.ballistic.model.ProductOffer; import group.goforward.ballistic.model.ProductOffer;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface ProductOfferRepository extends JpaRepository<ProductOffer, Integer> { public interface ProductOfferRepository extends JpaRepository<ProductOffer, Integer> {
List<ProductOffer> findByProductId(Integer productId); List<ProductOffer> findByProductId(Integer productId);
// Used by the /api/products/gunbuilder endpoint // Used by the /api/products/gunbuilder endpoint
List<ProductOffer> findByProductIdIn(Collection<Integer> productIds); List<ProductOffer> findByProductIdIn(Collection<Integer> productIds);
// Unique offer lookup for importer upsert // Unique offer lookup for importer upsert
Optional<ProductOffer> findByMerchantIdAndAvantlinkProductId( Optional<ProductOffer> findByMerchantIdAndAvantlinkProductId(
Integer merchantId, Integer merchantId,
String avantlinkProductId String avantlinkProductId
); );
} }

View File

@@ -1,53 +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.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Optional; import java.util.List;
import java.util.UUID;
import java.util.List; public interface ProductRepository extends JpaRepository<Product, Integer> {
import java.util.Collection;
// -------------------------------------------------
public interface ProductRepository extends JpaRepository<Product, Integer> { // Used by MerchantFeedImportServiceImpl
// -------------------------------------------------
Optional<Product> findByUuid(UUID uuid);
List<Product> findAllByBrandAndMpn(Brand brand, String mpn);
boolean existsBySlug(String slug);
List<Product> findAllByBrandAndUpc(Brand brand, String upc);
List<Product> findAllByBrandAndMpn(Brand brand, String mpn);
boolean existsBySlug(String slug);
List<Product> findAllByBrandAndUpc(Brand brand, String upc);
// -------------------------------------------------
// All products for a given platform (e.g. "AR-15") // Used by ProductController for platform views
List<Product> findByPlatform(String platform); // -------------------------------------------------
// Products filtered by platform + part roles (e.g. upper-receiver, barrel, etc.) @Query("""
List<Product> findByPlatformAndPartRoleIn(String platform, Collection<String> partRoles); SELECT p
FROM Product p
// ---------- Optimized variants for Gunbuilder (fetch brand to avoid N+1) ---------- JOIN FETCH p.brand b
WHERE p.platform = :platform
@Query(""" AND p.deletedAt IS NULL
SELECT p """)
FROM Product p List<Product> findByPlatformWithBrand(@Param("platform") String platform);
JOIN FETCH p.brand b
WHERE p.platform = :platform @Query("""
AND p.deletedAt IS NULL SELECT p
""") FROM Product p
List<Product> findByPlatformWithBrand(@Param("platform") String platform); JOIN FETCH p.brand b
WHERE p.platform = :platform
@Query(""" AND p.partRole IN :roles
SELECT p AND p.deletedAt IS NULL
FROM Product p """)
JOIN FETCH p.brand b List<Product> findByPlatformAndPartRoleInWithBrand(
WHERE p.platform = :platform @Param("platform") String platform,
AND p.partRole IN :partRoles @Param("roles") List<String> roles
AND p.deletedAt IS NULL );
""")
List<Product> findByPlatformAndPartRoleInWithBrand( // -------------------------------------------------
@Param("platform") String platform, // Used by Gunbuilder service (if you wired this)
@Param("partRoles") Collection<String> partRoles // -------------------------------------------------
);
@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

@@ -1,13 +1,13 @@
/** /**
* Provides the classes necessary for the Spring Repository for the ballistic -Builder application. * Provides the classes necessary for the Spring Repository for the ballistic -Builder application.
* This package includes Repository for Spring-Boot application * This package includes Repository for Spring-Boot application
* *
* *
* <p>The main entry point for managing the inventory is the * <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p> * {@link group.goforward.ballistic.BallisticApplication} class.</p>
* *
* @since 1.0 * @since 1.0
* @author Sean Strawsburg * @author Sean Strawsburg
* @version 1.1 * @version 1.1
*/ */
package group.goforward.ballistic.repos; package group.goforward.ballistic.repos;

View File

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

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

@@ -1,96 +1,96 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping; import group.goforward.ballistic.model.MerchantCategoryMapping;
import group.goforward.ballistic.model.ProductConfiguration; import group.goforward.ballistic.model.ProductConfiguration;
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class MerchantCategoryMappingService { public class MerchantCategoryMappingService {
private final MerchantCategoryMappingRepository mappingRepository; private final MerchantCategoryMappingRepository mappingRepository;
public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) {
this.mappingRepository = mappingRepository; this.mappingRepository = mappingRepository;
} }
public List<MerchantCategoryMapping> findByMerchant(Integer merchantId) { public List<MerchantCategoryMapping> findByMerchant(Integer merchantId) {
return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId);
} }
/** /**
* Resolve (or create) a mapping row for this merchant + raw category. * Resolve (or create) a mapping row for this merchant + raw category.
* - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set). * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set).
* - If it doesn't exist, creates a placeholder row with null mappings and returns it. * - If it doesn't exist, creates a placeholder row with null mappings and returns it.
* *
* The importer can then: * The importer can then:
* - skip rows where mappedPartRole is still null * - skip rows where mappedPartRole is still null
* - use mappedConfiguration if present * - use mappedConfiguration if present
*/ */
@Transactional @Transactional
public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) { public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) {
if (rawCategory == null || rawCategory.isBlank()) { if (rawCategory == null || rawCategory.isBlank()) {
return null; return null;
} }
String trimmed = rawCategory.trim(); String trimmed = rawCategory.trim();
return mappingRepository return mappingRepository
.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
.orElseGet(() -> { .orElseGet(() -> {
MerchantCategoryMapping mapping = new MerchantCategoryMapping(); MerchantCategoryMapping mapping = new MerchantCategoryMapping();
mapping.setMerchant(merchant); mapping.setMerchant(merchant);
mapping.setRawCategory(trimmed); mapping.setRawCategory(trimmed);
mapping.setMappedPartRole(null); mapping.setMappedPartRole(null);
mapping.setMappedConfiguration(null); mapping.setMappedConfiguration(null);
return mappingRepository.save(mapping); return mappingRepository.save(mapping);
}); });
} }
/** /**
* Upsert mapping (admin UI). * Upsert mapping (admin UI).
*/ */
@Transactional @Transactional
public MerchantCategoryMapping upsertMapping( public MerchantCategoryMapping upsertMapping(
Merchant merchant, Merchant merchant,
String rawCategory, String rawCategory,
String mappedPartRole, String mappedPartRole,
ProductConfiguration mappedConfiguration ProductConfiguration mappedConfiguration
) { ) {
String trimmed = rawCategory.trim(); String trimmed = rawCategory.trim();
MerchantCategoryMapping mapping = mappingRepository MerchantCategoryMapping mapping = mappingRepository
.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
.orElseGet(() -> { .orElseGet(() -> {
MerchantCategoryMapping m = new MerchantCategoryMapping(); MerchantCategoryMapping m = new MerchantCategoryMapping();
m.setMerchant(merchant); m.setMerchant(merchant);
m.setRawCategory(trimmed); m.setRawCategory(trimmed);
return m; return m;
}); });
mapping.setMappedPartRole( mapping.setMappedPartRole(
(mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim()
); );
mapping.setMappedConfiguration(mappedConfiguration); mapping.setMappedConfiguration(mappedConfiguration);
return mappingRepository.save(mapping); return mappingRepository.save(mapping);
} }
/** /**
* Backwards-compatible overload for existing callers (e.g. controller) * Backwards-compatible overload for existing callers (e.g. controller)
* that dont care about productConfiguration yet. * that dont care about productConfiguration yet.
*/ */
@Transactional @Transactional
public MerchantCategoryMapping upsertMapping( public MerchantCategoryMapping upsertMapping(
Merchant merchant, Merchant merchant,
String rawCategory, String rawCategory,
String mappedPartRole String mappedPartRole
) { ) {
// Delegate to the new method with `null` configuration // Delegate to the new method with `null` configuration
return upsertMapping(merchant, rawCategory, mappedPartRole, null); return upsertMapping(merchant, rawCategory, mappedPartRole, null);
} }
} }

View File

@@ -1,14 +1,14 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
public interface MerchantFeedImportService { public interface MerchantFeedImportService {
/** /**
* Full product + offer import for a given merchant. * Full product + offer import for a given merchant.
*/ */
void importMerchantFeed(Integer merchantId); void importMerchantFeed(Integer merchantId);
/** /**
* Offers-only sync (price / stock) for a given merchant. * Offers-only sync (price / stock) for a given merchant.
*/ */
void syncOffersOnly(Integer merchantId); void syncOffersOnly(Integer merchantId);
} }

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,17 +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 java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface PsaService { public interface PsaService {
List<Psa> findAll(); List<Psa> findAll();
Optional<Psa> findById(UUID id); Optional<Psa> findById(UUID id);
Psa save(Psa psa); Psa save(Psa psa);
void deleteById(UUID id); void deleteById(UUID id);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
/** /**
* Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application. * Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application.
* This package includes Services implementations for Spring-Boot application * This package includes Services implementations for Spring-Boot application
* *
* *
* <p>The main entry point for managing the inventory is the * <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BallisticApplication} class.</p> * {@link group.goforward.ballistic.BallisticApplication} class.</p>
* *
* @since 1.0 * @since 1.0
* @author Don Strawsburg * @author Don Strawsburg
* @version 1.1 * @version 1.1
*/ */
package group.goforward.ballistic.services.impl; package group.goforward.ballistic.services.impl;

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

@@ -1,70 +1,70 @@
// MerchantAdminDto.java // MerchantAdminDto.java
package group.goforward.ballistic.web.dto; package group.goforward.ballistic.web.dto;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
public class MerchantAdminDto { public class MerchantAdminDto {
private Integer id; private Integer id;
private String name; private String name;
private String feedUrl; private String feedUrl;
private String offerFeedUrl; private String offerFeedUrl;
private Boolean isActive; private Boolean isActive;
private OffsetDateTime lastFullImportAt; private OffsetDateTime lastFullImportAt;
private OffsetDateTime lastOfferSyncAt; private OffsetDateTime lastOfferSyncAt;
public Integer getId() { public Integer getId() {
return id; return id;
} }
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
public String getName() { public String getName() {
return name; return name;
} }
public void setName(String name) { public void setName(String name) {
this.name = name; this.name = name;
} }
public String getFeedUrl() { public String getFeedUrl() {
return feedUrl; return feedUrl;
} }
public void setFeedUrl(String feedUrl) { public void setFeedUrl(String feedUrl) {
this.feedUrl = feedUrl; this.feedUrl = feedUrl;
} }
public String getOfferFeedUrl() { public String getOfferFeedUrl() {
return offerFeedUrl; return offerFeedUrl;
} }
public void setOfferFeedUrl(String offerFeedUrl) { public void setOfferFeedUrl(String offerFeedUrl) {
this.offerFeedUrl = offerFeedUrl; this.offerFeedUrl = offerFeedUrl;
} }
public Boolean getIsActive() { public Boolean getIsActive() {
return isActive; return isActive;
} }
public void setIsActive(Boolean isActive) { public void setIsActive(Boolean isActive) {
this.isActive = isActive; this.isActive = isActive;
} }
public OffsetDateTime getLastFullImportAt() { public OffsetDateTime getLastFullImportAt() {
return lastFullImportAt; return lastFullImportAt;
} }
public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { public void setLastFullImportAt(OffsetDateTime lastFullImportAt) {
this.lastFullImportAt = lastFullImportAt; this.lastFullImportAt = lastFullImportAt;
} }
public OffsetDateTime getLastOfferSyncAt() { public OffsetDateTime getLastOfferSyncAt() {
return lastOfferSyncAt; return lastOfferSyncAt;
} }
public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) {
this.lastOfferSyncAt = lastOfferSyncAt; this.lastOfferSyncAt = lastOfferSyncAt;
} }
} }

View File

@@ -1,50 +1,50 @@
package group.goforward.ballistic.web.dto; package group.goforward.ballistic.web.dto;
public class MerchantCategoryMappingDto { public class MerchantCategoryMappingDto {
private Integer id; private Integer id;
private Integer merchantId; private Integer merchantId;
private String merchantName; private String merchantName;
private String rawCategory; private String rawCategory;
private String mappedPartRole; private String mappedPartRole;
public Integer getId() { public Integer getId() {
return id; return id;
} }
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
public Integer getMerchantId() { public Integer getMerchantId() {
return merchantId; return merchantId;
} }
public void setMerchantId(Integer merchantId) { public void setMerchantId(Integer merchantId) {
this.merchantId = merchantId; this.merchantId = merchantId;
} }
public String getMerchantName() { public String getMerchantName() {
return merchantName; return merchantName;
} }
public void setMerchantName(String merchantName) { public void setMerchantName(String merchantName) {
this.merchantName = merchantName; this.merchantName = merchantName;
} }
public String getRawCategory() { public String getRawCategory() {
return rawCategory; return rawCategory;
} }
public void setRawCategory(String rawCategory) { public void setRawCategory(String rawCategory) {
this.rawCategory = rawCategory; this.rawCategory = rawCategory;
} }
public String getMappedPartRole() { public String getMappedPartRole() {
return mappedPartRole; return mappedPartRole;
} }
public void setMappedPartRole(String mappedPartRole) { public void setMappedPartRole(String mappedPartRole) {
this.mappedPartRole = mappedPartRole; this.mappedPartRole = mappedPartRole;
} }
} }

View File

@@ -1,70 +1,70 @@
package group.goforward.ballistic.web.dto; package group.goforward.ballistic.web.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
public class ProductOfferDto { public class ProductOfferDto {
private String id; private String id;
private String merchantName; private String merchantName;
private BigDecimal price; private BigDecimal price;
private BigDecimal originalPrice; private BigDecimal originalPrice;
private boolean inStock; private boolean inStock;
private String buyUrl; private String buyUrl;
private OffsetDateTime lastUpdated; private OffsetDateTime lastUpdated;
public String getId() { public String getId() {
return id; return id;
} }
public void setId(String id) { public void setId(String id) {
this.id = id; this.id = id;
} }
public String getMerchantName() { public String getMerchantName() {
return merchantName; return merchantName;
} }
public void setMerchantName(String merchantName) { public void setMerchantName(String merchantName) {
this.merchantName = merchantName; this.merchantName = merchantName;
} }
public BigDecimal getPrice() { public BigDecimal getPrice() {
return price; return price;
} }
public void setPrice(BigDecimal price) { public void setPrice(BigDecimal price) {
this.price = price; this.price = price;
} }
public BigDecimal getOriginalPrice() { public BigDecimal getOriginalPrice() {
return originalPrice; return originalPrice;
} }
public void setOriginalPrice(BigDecimal originalPrice) { public void setOriginalPrice(BigDecimal originalPrice) {
this.originalPrice = originalPrice; this.originalPrice = originalPrice;
} }
public boolean isInStock() { public boolean isInStock() {
return inStock; return inStock;
} }
public void setInStock(boolean inStock) { public void setInStock(boolean inStock) {
this.inStock = inStock; this.inStock = inStock;
} }
public String getBuyUrl() { public String getBuyUrl() {
return buyUrl; return buyUrl;
} }
public void setBuyUrl(String buyUrl) { public void setBuyUrl(String buyUrl) {
this.buyUrl = buyUrl; this.buyUrl = buyUrl;
} }
public OffsetDateTime getLastUpdated() { public OffsetDateTime getLastUpdated() {
return lastUpdated; return lastUpdated;
} }
public void setLastUpdated(OffsetDateTime lastUpdated) { public void setLastUpdated(OffsetDateTime lastUpdated) {
this.lastUpdated = lastUpdated; this.lastUpdated = lastUpdated;
} }
} }

View File

@@ -1,79 +1,79 @@
package group.goforward.ballistic.web.dto; package group.goforward.ballistic.web.dto;
import java.math.BigDecimal; import java.math.BigDecimal;
public class ProductSummaryDto { public class ProductSummaryDto {
private String id; // product UUID as string private String id; // product UUID as string
private String name; private String name;
private String brand; private String brand;
private String platform; private String platform;
private String partRole; private String partRole;
private String categoryKey; private String categoryKey;
private BigDecimal price; private BigDecimal price;
private String buyUrl; private String buyUrl;
public String getId() { public String getId() {
return id; return id;
} }
public void setId(String id) { public void setId(String id) {
this.id = id; this.id = id;
} }
public String getName() { public String getName() {
return name; return name;
} }
public void setName(String name) { public void setName(String name) {
this.name = name; this.name = name;
} }
public String getBrand() { public String getBrand() {
return brand; return brand;
} }
public void setBrand(String brand) { public void setBrand(String brand) {
this.brand = brand; this.brand = brand;
} }
public String getPlatform() { public String getPlatform() {
return platform; return platform;
} }
public void setPlatform(String platform) { public void setPlatform(String platform) {
this.platform = platform; this.platform = platform;
} }
public String getPartRole() { public String getPartRole() {
return partRole; return partRole;
} }
public void setPartRole(String partRole) { public void setPartRole(String partRole) {
this.partRole = partRole; this.partRole = partRole;
} }
public String getCategoryKey() { public String getCategoryKey() {
return categoryKey; return categoryKey;
} }
public void setCategoryKey(String categoryKey) { public void setCategoryKey(String categoryKey) {
this.categoryKey = categoryKey; this.categoryKey = categoryKey;
} }
public BigDecimal getPrice() { public BigDecimal getPrice() {
return price; return price;
} }
public void setPrice(BigDecimal price) { public void setPrice(BigDecimal price) {
this.price = price; this.price = price;
} }
public String getBuyUrl() { public String getBuyUrl() {
return buyUrl; return buyUrl;
} }
public void setBuyUrl(String buyUrl) { public void setBuyUrl(String buyUrl) {
this.buyUrl = buyUrl; this.buyUrl = buyUrl;
} }
} }

View File

@@ -1,32 +1,32 @@
package group.goforward.ballistic.web.dto; package group.goforward.ballistic.web.dto;
public class UpsertMerchantCategoryMappingRequest { public class UpsertMerchantCategoryMappingRequest {
private Integer merchantId; private Integer merchantId;
private String rawCategory; private String rawCategory;
private String mappedPartRole; // can be null to "unmap" private String mappedPartRole; // can be null to "unmap"
public Integer getMerchantId() { public Integer getMerchantId() {
return merchantId; return merchantId;
} }
public void setMerchantId(Integer merchantId) { public void setMerchantId(Integer merchantId) {
this.merchantId = merchantId; this.merchantId = merchantId;
} }
public String getRawCategory() { public String getRawCategory() {
return rawCategory; return rawCategory;
} }
public void setRawCategory(String rawCategory) { public void setRawCategory(String rawCategory) {
this.rawCategory = rawCategory; this.rawCategory = rawCategory;
} }
public String getMappedPartRole() { public String getMappedPartRole() {
return mappedPartRole; return mappedPartRole;
} }
public void setMappedPartRole(String mappedPartRole) { public void setMappedPartRole(String mappedPartRole) {
this.mappedPartRole = mappedPartRole; this.mappedPartRole = mappedPartRole;
} }
} }

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

@@ -1,30 +1,30 @@
package group.goforward.ballistic.web.mapper; package group.goforward.ballistic.web.mapper;
import group.goforward.ballistic.model.Product; import group.goforward.ballistic.model.Product;
import group.goforward.ballistic.web.dto.ProductSummaryDto; import group.goforward.ballistic.web.dto.ProductSummaryDto;
import java.math.BigDecimal; import java.math.BigDecimal;
public class ProductMapper { public class ProductMapper {
public static ProductSummaryDto toSummary(Product product, BigDecimal price, String buyUrl) { public static ProductSummaryDto toSummary(Product product, BigDecimal price, String buyUrl) {
ProductSummaryDto dto = new ProductSummaryDto(); ProductSummaryDto dto = new ProductSummaryDto();
// Product ID -> String // Product ID -> String
dto.setId(String.valueOf(product.getId())); dto.setId(String.valueOf(product.getId()));
dto.setName(product.getName()); dto.setName(product.getName());
dto.setBrand(product.getBrand() != null ? product.getBrand().getName() : null); dto.setBrand(product.getBrand() != null ? product.getBrand().getName() : null);
dto.setPlatform(product.getPlatform()); dto.setPlatform(product.getPlatform());
dto.setPartRole(product.getPartRole()); dto.setPartRole(product.getPartRole());
// Use rawCategoryKey from the Product entity // Use rawCategoryKey from the Product entity
dto.setCategoryKey(product.getRawCategoryKey()); dto.setCategoryKey(product.getRawCategoryKey());
// Price + buy URL from offers // Price + buy URL from offers
dto.setPrice(price); dto.setPrice(price);
dto.setBuyUrl(buyUrl); dto.setBuyUrl(buyUrl);
return dto; return dto;
} }
} }

View File