mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2025-12-05 18:46:44 -05:00
Compare commits
33 Commits
4c0a3bd12d
...
sean
| Author | SHA1 | Date | |
|---|---|---|---|
| 2969cdfa23 | |||
| e986fa97ca | |||
| c283ec15b6 | |||
| 9096ddd165 | |||
| 3d1501cc87 | |||
| 3ae68f30c0 | |||
| f3626af709 | |||
| 756a6791fc | |||
| d344b372d1 | |||
| 74a5c42e26 | |||
| 5e3f7d5044 | |||
| 31815d3145 | |||
| 4138edf45d | |||
| 85b00e9d99 | |||
| 7e1b33efdf | |||
| 009e512a66 | |||
| 9fabf30406 | |||
| 346ccc3813 | |||
| 9779bdb5c0 | |||
| d7ae362c23 | |||
| 7fb24fdde3 | |||
| c4d2adad1a | |||
| 0b2b3afd0c | |||
| f1dcd10a79 | |||
| 66d45a1113 | |||
| 0f5978fd11 | |||
| 7166b92d32 | |||
| 855f1c23c9 | |||
| 2ef96939f4 | |||
| 52c49c7238 | |||
| 140b75621f | |||
| f539c64588 | |||
| 87b3c4bff8 |
31
.idea/dataSources.xml
generated
Normal file
31
.idea/dataSources.xml
generated
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="r710" uuid="e6a29f5c-71d9-45f0-931b-554bcf8a94ba">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://r710.dev.gofwd.group:5433/postgres</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="ss_builder@r710.gofwd.group" uuid="e0fa459b-2f6c-45f1-9c41-66423c870df9">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<imported>true</imported>
|
||||||
|
<remarks>$PROJECT_DIR$/src/main/resources/application.properties</remarks>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://r710.gofwd.group:5433/ss_builder</jdbc-url>
|
||||||
|
<jdbc-additional-properties>
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||||
|
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||||
|
</jdbc-additional-properties>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
67
README.md
67
README.md
@@ -0,0 +1,67 @@
|
|||||||
|
# Ballistic Builder ( The Armory?) Backend
|
||||||
|
### Internal Engine for the Shadow System Armory?
|
||||||
|
|
||||||
|
The Ballistic Backend is the operational backbone behind the Shadown System Armory and its admin tools. It ingests merchant feeds, normalizes product data, manages categories, synchronizes prices, and powers the compatibility, pricing, and product logic behind the consumer-facing Builder.
|
||||||
|
|
||||||
|
It’s 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
|
||||||
|
|
||||||
|
### **Merchant Feed Ingestion**
|
||||||
|
- Pulls AvantLink feeds (CSV or TSV)
|
||||||
|
- Automatically detects delimiters
|
||||||
|
- Normalizes raw merchant fields
|
||||||
|
- Creates or updates product records
|
||||||
|
- Upserts price and stock offers
|
||||||
|
- Tracks first-seen / last-seen timestamps
|
||||||
|
- Safely handles malformed or incomplete rows
|
||||||
|
- Ensures repeat imports never duplicate offers
|
||||||
|
|
||||||
|
### **Category Mapping Engine**
|
||||||
|
- Identifies every unique raw category coming from each merchant feed
|
||||||
|
- Exposes *unmapped* categories in the admin UI
|
||||||
|
- Allows you to assign:
|
||||||
|
- Part Role
|
||||||
|
- Product Configuration (Stripped, Complete, Kit, etc.)
|
||||||
|
- Applies mappings automatically on future imports
|
||||||
|
- Respects manual overrides such as `platform_locked`
|
||||||
|
|
||||||
|
### **Builder Support**
|
||||||
|
The frontend Builder depends on this backend for:
|
||||||
|
|
||||||
|
- Loading parts grouped by role
|
||||||
|
- Offering compatible options
|
||||||
|
- Calculating build cost
|
||||||
|
- Comparing offers across merchants
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Spring Boot 3.x**
|
||||||
|
- **Java 17**
|
||||||
|
- **PostgreSQL**
|
||||||
|
- **Hibernate (JPA)**
|
||||||
|
- **HikariCP**
|
||||||
|
- **Apache Commons CSV**
|
||||||
|
- **Maven**
|
||||||
|
- **REST API**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- Java 17 or newer
|
||||||
|
- PostgreSQL running locally
|
||||||
|
- Port 8080 open (default backend port)
|
||||||
|
|
||||||
|
### Run Development Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run
|
||||||
39
action1.yaml
Normal file
39
action1.yaml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# File: .gitea/workflows/build-and-upload.yml
|
||||||
|
name: Build and Upload Artifact
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# Step 1: Check out repository code
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Step 2: Set up Node.js (example for a JS project; adjust for your stack)
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
# Step 3: Install dependencies
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
# Step 4: Build project
|
||||||
|
- name: Build project
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
# Step 5: Upload build output as artifact
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-output
|
||||||
|
path: dist/ # Change to your build output directory
|
||||||
|
retention-days: 7 # Optional: how long to keep artifact
|
||||||
34
docker/backend/Dockerfile
Normal file
34
docker/backend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# 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"]
|
||||||
43
docker/docker-compose.yaml
Normal file
43
docker/docker-compose.yaml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- 1. Spring API Service (Backend) ---
|
||||||
|
ss_builder-api:
|
||||||
|
build:
|
||||||
|
context: ./backend # Path to your Spring project's root folder
|
||||||
|
dockerfile: Dockerfile # Assumes you have a Dockerfile in ./backend
|
||||||
|
container_name: ss_builder-api
|
||||||
|
ports:
|
||||||
|
- "8080:8080" # Map host port 8080 to container port 8080
|
||||||
|
environment:
|
||||||
|
# These environment variables link the API to the database service defined below
|
||||||
|
- SPRING_DATASOURCE_URL=jdbc:postgresql://r710.dev.gofwd.group:5433/ss_builder
|
||||||
|
- SPRING_DATASOURCE_USERNAME=dba
|
||||||
|
- SPRING_DATASOURCE_PASSWORD=!@#Qwerty
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
# --- 2. Next.js App Service (Frontend) ---
|
||||||
|
nextjs-app:
|
||||||
|
build:
|
||||||
|
context: ./frontend # Path to your Next.js project's root folder
|
||||||
|
dockerfile: Dockerfile # Assumes you have a Dockerfile in ./frontend
|
||||||
|
container_name: ss_builder-app
|
||||||
|
ports:
|
||||||
|
- "3000:3000" # Map host port 3000 to container port 3000
|
||||||
|
environment:
|
||||||
|
# This variable is crucial: Next.js needs the URL for the Spring API
|
||||||
|
# Use the Docker internal service name 'spring-api' and its port 8080
|
||||||
|
- NEXT_PUBLIC_API_URL=http://ss_builder-api:8080
|
||||||
|
# For local testing, you might need the host IP for Next.js to call back
|
||||||
|
# - NEXT_PUBLIC_API_URL_LOCAL=http://localhost:8080
|
||||||
|
depends_on:
|
||||||
|
- ss_builder-api
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
|
|
||||||
|
# --- Docker Network for Inter-Container Communication ---
|
||||||
|
networks:
|
||||||
|
app-network:
|
||||||
|
driver: bridge
|
||||||
22
docker/frontend/Dockerfile
Normal file
22
docker/frontend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Stage 1: Build the static assets
|
||||||
|
FROM node:20-alpine as builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
# Run the Next.js build command
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Run the production application (Next.js server)
|
||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
# Copy only the necessary files for running the app
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV production
|
||||||
|
EXPOSE 3000
|
||||||
|
# Run the Next.js production server
|
||||||
|
CMD ["npm", "start"]
|
||||||
213
importLogic.md
Normal file
213
importLogic.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Ballistic Import Pipeline
|
||||||
|
A high-level overview of how merchant data flows through the Spring ETL system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document explains how the Ballistic backend:
|
||||||
|
|
||||||
|
1. Fetches merchant product feeds (CSV/TSV)
|
||||||
|
2. Normalizes raw data into structured entities
|
||||||
|
3. Updates products and offers in an idempotent way
|
||||||
|
4. Supports two sync modes:
|
||||||
|
- Full Import
|
||||||
|
- Offer-Only Sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. High-Level Flow
|
||||||
|
|
||||||
|
## ASCII Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
│ /admin/imports/{id} │
|
||||||
|
│ (Full Import Trigger) │
|
||||||
|
└─────────────┬────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ importMerchantFeed(merchantId)│
|
||||||
|
└─────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────┐
|
||||||
|
│ readFeedRowsForMerchant() │
|
||||||
|
│ - auto-detect delimiter │
|
||||||
|
│ - parse CSV/TSV → MerchantFeedRow objects │
|
||||||
|
└─────────────────┬──────────────────────────────────────┘
|
||||||
|
│ List<MerchantFeedRow>
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────┐
|
||||||
|
│ For each MerchantFeedRow row: │
|
||||||
|
│ resolveBrand() │
|
||||||
|
│ upsertProduct() │
|
||||||
|
│ - find existing via brand+mpn/upc │
|
||||||
|
│ - update fields (mapped partRole) │
|
||||||
|
│ upsertOfferFromRow() │
|
||||||
|
└──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Full Import Explained
|
||||||
|
|
||||||
|
Triggered by:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/imports/{merchantId}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 1 — Load merchant
|
||||||
|
Using `merchantRepository.findById()`.
|
||||||
|
|
||||||
|
### Step 2 — Parse feed rows
|
||||||
|
`readFeedRowsForMerchant()`:
|
||||||
|
- Auto-detects delimiter (`\t`, `,`, `;`)
|
||||||
|
- Validates required headers
|
||||||
|
- Parses each row into `MerchantFeedRow`
|
||||||
|
|
||||||
|
### Step 3 — Process each row
|
||||||
|
|
||||||
|
For each parsed row:
|
||||||
|
|
||||||
|
#### a. resolveBrand()
|
||||||
|
- Finds or creates brand
|
||||||
|
- Defaults to “Aero Precision” if missing
|
||||||
|
|
||||||
|
#### b. upsertProduct()
|
||||||
|
Dedupes by:
|
||||||
|
|
||||||
|
1. Brand + MPN
|
||||||
|
2. Brand + UPC (currently SKU placeholder)
|
||||||
|
|
||||||
|
If no match → create new product.
|
||||||
|
|
||||||
|
Then applies:
|
||||||
|
- Name + slug
|
||||||
|
- Descriptions
|
||||||
|
- Images
|
||||||
|
- MPN/identifiers
|
||||||
|
- Platform inference
|
||||||
|
- Category mapping
|
||||||
|
- Part role inference
|
||||||
|
|
||||||
|
#### c. upsertOfferFromRow()
|
||||||
|
Creates or updates a ProductOffer:
|
||||||
|
- Prices
|
||||||
|
- Stock
|
||||||
|
- Buy URL
|
||||||
|
- lastSeenAt
|
||||||
|
- firstSeenAt when newly created
|
||||||
|
|
||||||
|
Idempotent — does not duplicate offers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Offer-Only Sync
|
||||||
|
|
||||||
|
Triggered by:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /admin/imports/{merchantId}/offers-only
|
||||||
|
```
|
||||||
|
|
||||||
|
Does NOT:
|
||||||
|
- Create products
|
||||||
|
- Update product fields
|
||||||
|
|
||||||
|
It only updates:
|
||||||
|
- price
|
||||||
|
- originalPrice
|
||||||
|
- inStock
|
||||||
|
- buyUrl
|
||||||
|
- lastSeenAt
|
||||||
|
|
||||||
|
If the offer does not exist, it is skipped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Auto-Detecting CSV/TSV Parser
|
||||||
|
|
||||||
|
The parser:
|
||||||
|
|
||||||
|
- Attempts multiple delimiters
|
||||||
|
- Validates headers
|
||||||
|
- Handles malformed or short rows
|
||||||
|
- Never throws on missing columns
|
||||||
|
- Returns clean MerchantFeedRow objects
|
||||||
|
|
||||||
|
Designed for messy merchant feeds.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Entities Updated During Import
|
||||||
|
|
||||||
|
### Product
|
||||||
|
- name
|
||||||
|
- slug
|
||||||
|
- short/long description
|
||||||
|
- main image
|
||||||
|
- mpn
|
||||||
|
- upc (future)
|
||||||
|
- platform
|
||||||
|
- rawCategoryKey
|
||||||
|
- partRole
|
||||||
|
|
||||||
|
### ProductOffer
|
||||||
|
- merchant
|
||||||
|
- product
|
||||||
|
- avantlinkProductId (SKU placeholder)
|
||||||
|
- price
|
||||||
|
- originalPrice
|
||||||
|
- inStock
|
||||||
|
- buyUrl
|
||||||
|
- lastSeenAt
|
||||||
|
- firstSeenAt
|
||||||
|
|
||||||
|
### Merchant
|
||||||
|
- lastFullImportAt
|
||||||
|
- lastOfferSyncAt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Extension Points
|
||||||
|
|
||||||
|
You can extend the import pipeline in these areas:
|
||||||
|
|
||||||
|
- Add per-merchant column mapping
|
||||||
|
- Add true UPC parsing
|
||||||
|
- Support multi-platform parts
|
||||||
|
- Improve partRole inference
|
||||||
|
- Implement global deduplication across merchants
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Quick Reference: Main Methods
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| importMerchantFeed | Full product + offer import |
|
||||||
|
| readFeedRowsForMerchant | Detect delimiter + parse feed |
|
||||||
|
| resolveBrand | Normalize brand names |
|
||||||
|
| upsertProduct | Idempotent product write |
|
||||||
|
| updateProductFromRow | Apply product fields |
|
||||||
|
| upsertOfferFromRow | Idempotent offer write |
|
||||||
|
| syncOffersOnly | Offer-only sync |
|
||||||
|
| upsertOfferOnlyFromRow | Update existing offers |
|
||||||
|
| detectCsvFormat | Auto-detect delimiter |
|
||||||
|
| fetchFeedRows | Simpler parser for offers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Summary
|
||||||
|
|
||||||
|
The Ballistic importer is:
|
||||||
|
|
||||||
|
- Robust against bad data
|
||||||
|
- Idempotent and safe
|
||||||
|
- Flexible for multiple merchants
|
||||||
|
- Extensible for long-term scaling
|
||||||
|
|
||||||
|
This pipeline powers the product catalog and offer data for the Ballistic ecosystem.
|
||||||
70
pom.xml
70
pom.xml
@@ -1,22 +1,28 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
<modelVersion>4.0.0</modelVersion>
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.4.3</version>
|
<version>3.4.3</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>group.goforward</groupId>
|
<groupId>group.goforward</groupId>
|
||||||
<artifactId>ballistic</artifactId>
|
<artifactId>ballistic</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
<name>ballistic</name>
|
<name>ballistic</name>
|
||||||
<description>Ballistic Builder API</description>
|
<description>Ballistic Builder API</description>
|
||||||
|
|
||||||
<url/>
|
<url/>
|
||||||
|
|
||||||
<licenses>
|
<licenses>
|
||||||
<license/>
|
<license/>
|
||||||
</licenses>
|
</licenses>
|
||||||
|
|
||||||
<developers>
|
<developers>
|
||||||
<developer>
|
<developer>
|
||||||
<name>Don Strawsburg</name>
|
<name>Don Strawsburg</name>
|
||||||
@@ -25,30 +31,37 @@
|
|||||||
</developer>
|
</developer>
|
||||||
<developer>
|
<developer>
|
||||||
<name>Sean Strawsburg</name>
|
<name>Sean Strawsburg</name>
|
||||||
<email>don@goforward.group</email>
|
<email>sean@goforward.group</email>
|
||||||
<organization>Forward Group, LLC</organization>
|
<organization>Forward Group, LLC</organization>
|
||||||
</developer>
|
</developer>
|
||||||
</developers>
|
</developers>
|
||||||
|
|
||||||
<scm>
|
<scm>
|
||||||
<connection/>
|
<connection></connection>
|
||||||
<developerConnection/>
|
<developerConnection>scm:git:https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git</developerConnection>
|
||||||
<tag/>
|
<tag/>
|
||||||
<url/>
|
<url/>
|
||||||
</scm>
|
</scm>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
<maven.compiler.source>${java.version}</maven.compiler.source>
|
<maven.compiler.source>${java.version}</maven.compiler.source>
|
||||||
<maven.compiler.target>${java.version}</maven.compiler.target>
|
<maven.compiler.target>${java.version}</maven.compiler.target>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!--
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-rest</artifactId>
|
<artifactId>spring-boot-starter-data-rest</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
-->
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
@@ -60,33 +73,63 @@
|
|||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springdoc</groupId>
|
<groupId>org.springdoc</groupId>
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
<version>2.8.5</version>
|
<version>2.8.5</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!--<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
<!-- Jakarta persistence API (optional, JPA starter already brings it transitively) -->
|
||||||
<artifactId>spring-boot-docker-compose</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>-->
|
|
||||||
<!-- Jakarta / validation API is pulled by starters; explicit jakarta persistence if needed -->
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>jakarta.persistence</groupId>
|
<groupId>jakarta.persistence</groupId>
|
||||||
<artifactId>jakarta.persistence-api</artifactId>
|
<artifactId>jakarta.persistence-api</artifactId>
|
||||||
<version>3.1.0</version>
|
<version>3.1.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>42.7.7</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.apache.commons</groupId>
|
||||||
|
<artifactId>commons-csv</artifactId>
|
||||||
|
<version>1.11.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Security -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JSON Web Tokens (JJWT) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>0.11.5</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
@@ -100,6 +143,7 @@
|
|||||||
<target>${maven.compiler.target}</target>
|
<target>${maven.compiler.target}</target>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
@@ -107,4 +151,4 @@
|
|||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
@@ -2,6 +2,9 @@ package group.goforward.ballistic;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param <T>
|
||||||
|
*/
|
||||||
public class ApiResponse<T> {
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
private static final String API_SUCCESS = "success";
|
private static final String API_SUCCESS = "success";
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ package group.goforward.ballistic;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
import org.springframework.boot.autoconfigure.domain.EntityScan;
|
||||||
import org.springframework.context.annotation.ComponentScan;
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
//@ComponentScan(basePackages = "group.goforward.ballistic")
|
@EnableCaching
|
||||||
@EntityScan(basePackages = "group.goforward.ballistic.model")
|
@EntityScan(basePackages = "group.goforward.ballistic.model")
|
||||||
@EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos")
|
@EnableJpaRepositories(basePackages = "group.goforward.ballistic.repos")
|
||||||
public class BallisticApplication {
|
public class BallisticApplication {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package group.goforward.ballistic.configuration;
|
||||||
|
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class CacheConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CacheManager cacheManager() {
|
||||||
|
// Simple in-memory cache for dev/local
|
||||||
|
return new ConcurrentMapCacheManager("gunbuilderProducts");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,15 +26,13 @@ public class CorsConfig {
|
|||||||
"http://localhost:4201",
|
"http://localhost:4201",
|
||||||
"http://localhost:8070",
|
"http://localhost:8070",
|
||||||
"https://localhost:8070",
|
"https://localhost:8070",
|
||||||
"http://192.168.11.210:8070",
|
"http://localhost:8080",
|
||||||
"https://192.168.11.210:8070",
|
"https://localhost:8080",
|
||||||
"http://localhost:4200",
|
"http://localhost:3000",
|
||||||
"http://citysites.gofwd.group",
|
"https://localhost:3000",
|
||||||
"https://citysites.gofwd.group",
|
"https://localhost:3000/gunbuilder",
|
||||||
"http://citysites.gofwd.group:8070",
|
"http://localhost:3000/gunbuilder"
|
||||||
"https://citysites.gofwd.group:8070"
|
));
|
||||||
|
|
||||||
));
|
|
||||||
|
|
||||||
// Allow all headers
|
// Allow all headers
|
||||||
config.addAllowedHeader("*");
|
config.addAllowedHeader("*");
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package group.goforward.ballistic.configuration;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
|
// @Configuration
|
||||||
|
// public class PasswordConfig {
|
||||||
|
|
||||||
|
// @Bean
|
||||||
|
// public PasswordEncoder passwordEncoder() {
|
||||||
|
// // BCrypt default password
|
||||||
|
// return new BCryptPasswordEncoder();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package group.goforward.ballistic.configuration;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.sessionManagement(sm ->
|
||||||
|
sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
)
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
|
||||||
|
// Auth endpoints always open
|
||||||
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
|
|
||||||
|
// Swagger / docs
|
||||||
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
|
||||||
|
// Health
|
||||||
|
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||||
|
|
||||||
|
// Public product endpoints
|
||||||
|
.requestMatchers("/api/products/gunbuilder/**").permitAll()
|
||||||
|
|
||||||
|
// Everything else (for now) also open – we can tighten later
|
||||||
|
.anyRequest().permitAll()
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
// BCrypt is a solid default for user passwords
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(
|
||||||
|
AuthenticationConfiguration configuration
|
||||||
|
) throws Exception {
|
||||||
|
return configuration.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Provides the classes necessary for the Spring Configurations for the ballistic -Builder application.
|
||||||
|
* This package includes Configurations for Spring-Boot application
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* <p>The main entry point for managing the inventory is the
|
||||||
|
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
|
||||||
|
*
|
||||||
|
* @since 1.0
|
||||||
|
* @author Don Strawsburg
|
||||||
|
* @version 1.1
|
||||||
|
*/
|
||||||
package group.goforward.ballistic.configuration;
|
package group.goforward.ballistic.configuration;
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package group.goforward.ballistic.controllers;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
import group.goforward.ballistic.repos.UserRepository;
|
||||||
|
import group.goforward.ballistic.security.JwtService;
|
||||||
|
import group.goforward.ballistic.web.dto.auth.AuthResponse;
|
||||||
|
import group.goforward.ballistic.web.dto.auth.LoginRequest;
|
||||||
|
import group.goforward.ballistic.web.dto.auth.RegisterRequest;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@CrossOrigin
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final UserRepository users;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtService jwtService;
|
||||||
|
|
||||||
|
public AuthController(
|
||||||
|
UserRepository users,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtService jwtService
|
||||||
|
) {
|
||||||
|
this.users = users;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||||
|
String email = request.getEmail().trim().toLowerCase();
|
||||||
|
|
||||||
|
if (users.existsByEmailIgnoreCaseAndDeletedAtIsNull(email)) {
|
||||||
|
return ResponseEntity
|
||||||
|
.status(HttpStatus.CONFLICT)
|
||||||
|
.body("Email is already registered");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = new User();
|
||||||
|
// Let DB generate id
|
||||||
|
user.setUuid(UUID.randomUUID());
|
||||||
|
user.setEmail(email);
|
||||||
|
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setDisplayName(request.getDisplayName());
|
||||||
|
user.setRole("USER");
|
||||||
|
user.setIsActive(true);
|
||||||
|
user.setCreatedAt(OffsetDateTime.now());
|
||||||
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
users.save(user);
|
||||||
|
|
||||||
|
String token = jwtService.generateToken(user);
|
||||||
|
|
||||||
|
AuthResponse response = new AuthResponse(
|
||||||
|
token,
|
||||||
|
user.getEmail(),
|
||||||
|
user.getDisplayName(),
|
||||||
|
user.getRole()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||||
|
String email = request.getEmail().trim().toLowerCase();
|
||||||
|
|
||||||
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (user == null || !user.getIsActive()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setLastLoginAt(OffsetDateTime.now());
|
||||||
|
user.incrementLoginCount();
|
||||||
|
user.setUpdatedAt(OffsetDateTime.now());
|
||||||
|
users.save(user);
|
||||||
|
|
||||||
|
String token = jwtService.generateToken(user);
|
||||||
|
|
||||||
|
AuthResponse response = new AuthResponse(
|
||||||
|
token,
|
||||||
|
user.getEmail(),
|
||||||
|
user.getDisplayName(),
|
||||||
|
user.getRole()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -1,22 +1,39 @@
|
|||||||
package group.goforward.ballistic.controllers;
|
package group.goforward.ballistic.controllers;
|
||||||
|
|
||||||
import group.goforward.ballistic.imports.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")
|
||||||
public class ImportController {
|
@CrossOrigin(origins = "http://localhost:3000")
|
||||||
|
public class ImportController {
|
||||||
private final MerchantFeedImportService importService;
|
|
||||||
|
private final MerchantFeedImportService merchantFeedImportService;
|
||||||
public ImportController(MerchantFeedImportService importService) {
|
|
||||||
this.importService = importService;
|
public ImportController(MerchantFeedImportService merchantFeedImportService) {
|
||||||
}
|
this.merchantFeedImportService = merchantFeedImportService;
|
||||||
|
}
|
||||||
@PostMapping("/{merchantId}")
|
|
||||||
public ResponseEntity<Void> importMerchant(@PathVariable Integer merchantId) {
|
/**
|
||||||
importService.importMerchantFeed(merchantId);
|
* Full product + offer import for a merchant.
|
||||||
return ResponseEntity.accepted().build();
|
*
|
||||||
}
|
* POST /admin/imports/{merchantId}
|
||||||
|
*/
|
||||||
|
@PostMapping("/{merchantId}")
|
||||||
|
public ResponseEntity<Void> importMerchant(@PathVariable Integer merchantId) {
|
||||||
|
merchantFeedImportService.importMerchantFeed(merchantId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers-only sync (price/stock) for a merchant.
|
||||||
|
*
|
||||||
|
* POST /admin/imports/{merchantId}/offers-only
|
||||||
|
*/
|
||||||
|
@PostMapping("/{merchantId}/offers-only")
|
||||||
|
public ResponseEntity<Void> syncOffersOnly(@PathVariable Integer merchantId) {
|
||||||
|
merchantFeedImportService.syncOffersOnly(merchantId);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// MerchantAdminController.java
|
||||||
|
package group.goforward.ballistic.controllers;
|
||||||
|
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import group.goforward.ballistic.model.Merchant;
|
||||||
|
import group.goforward.ballistic.repos.MerchantRepository;
|
||||||
|
import group.goforward.ballistic.web.dto.MerchantAdminDto;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/merchants")
|
||||||
|
@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug
|
||||||
|
public class MerchantAdminController {
|
||||||
|
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
|
||||||
|
public MerchantAdminController(MerchantRepository merchantRepository) {
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<MerchantAdminDto> listMerchants() {
|
||||||
|
return merchantRepository.findAll().stream().map(this::toDto).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public MerchantAdminDto updateMerchant(
|
||||||
|
@PathVariable Integer id,
|
||||||
|
@RequestBody MerchantAdminDto payload
|
||||||
|
) {
|
||||||
|
Merchant merchant = merchantRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Merchant not found"));
|
||||||
|
|
||||||
|
merchant.setFeedUrl(payload.getFeedUrl());
|
||||||
|
merchant.setOfferFeedUrl(payload.getOfferFeedUrl());
|
||||||
|
merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true);
|
||||||
|
// don’t touch last* here; those are set by import jobs
|
||||||
|
|
||||||
|
merchant = merchantRepository.save(merchant);
|
||||||
|
return toDto(merchant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MerchantAdminDto toDto(Merchant m) {
|
||||||
|
MerchantAdminDto dto = new MerchantAdminDto();
|
||||||
|
dto.setId(m.getId());
|
||||||
|
dto.setName(m.getName());
|
||||||
|
dto.setFeedUrl(m.getFeedUrl());
|
||||||
|
dto.setOfferFeedUrl(m.getOfferFeedUrl());
|
||||||
|
dto.setIsActive(m.getIsActive());
|
||||||
|
dto.setLastFullImportAt(m.getLastFullImportAt());
|
||||||
|
dto.setLastOfferSyncAt(m.getLastOfferSyncAt());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package group.goforward.ballistic.controllers;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.Merchant;
|
||||||
|
import group.goforward.ballistic.model.MerchantCategoryMapping;
|
||||||
|
import group.goforward.ballistic.repos.MerchantRepository;
|
||||||
|
import group.goforward.ballistic.services.MerchantCategoryMappingService;
|
||||||
|
import group.goforward.ballistic.web.dto.MerchantCategoryMappingDto;
|
||||||
|
import group.goforward.ballistic.web.dto.UpsertMerchantCategoryMappingRequest;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/admin/merchant-category-mappings")
|
||||||
|
@CrossOrigin
|
||||||
|
public class MerchantCategoryMappingController {
|
||||||
|
|
||||||
|
private final MerchantCategoryMappingService mappingService;
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
|
||||||
|
public MerchantCategoryMappingController(
|
||||||
|
MerchantCategoryMappingService mappingService,
|
||||||
|
MerchantRepository merchantRepository
|
||||||
|
) {
|
||||||
|
this.mappingService = mappingService;
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<MerchantCategoryMappingDto> listMappings(
|
||||||
|
@RequestParam("merchantId") Integer merchantId
|
||||||
|
) {
|
||||||
|
List<MerchantCategoryMapping> mappings = mappingService.findByMerchant(merchantId);
|
||||||
|
return mappings.stream()
|
||||||
|
.map(this::toDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public MerchantCategoryMappingDto upsertMapping(
|
||||||
|
@RequestBody UpsertMerchantCategoryMappingRequest request
|
||||||
|
) {
|
||||||
|
Merchant merchant = merchantRepository
|
||||||
|
.findById(request.getMerchantId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + request.getMerchantId()));
|
||||||
|
|
||||||
|
MerchantCategoryMapping mapping = mappingService.upsertMapping(
|
||||||
|
merchant,
|
||||||
|
request.getRawCategory(),
|
||||||
|
request.getMappedPartRole()
|
||||||
|
);
|
||||||
|
|
||||||
|
return toDto(mapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MerchantCategoryMappingDto toDto(MerchantCategoryMapping mapping) {
|
||||||
|
MerchantCategoryMappingDto dto = new MerchantCategoryMappingDto();
|
||||||
|
dto.setId(mapping.getId());
|
||||||
|
dto.setMerchantId(mapping.getMerchant().getId());
|
||||||
|
dto.setMerchantName(mapping.getMerchant().getName());
|
||||||
|
dto.setRawCategory(mapping.getRawCategory());
|
||||||
|
dto.setMappedPartRole(mapping.getMappedPartRole());
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package group.goforward.ballistic.controllers;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.Product;
|
||||||
|
import group.goforward.ballistic.model.ProductOffer;
|
||||||
|
import group.goforward.ballistic.repos.ProductOfferRepository;
|
||||||
|
import group.goforward.ballistic.web.dto.ProductOfferDto;
|
||||||
|
import group.goforward.ballistic.repos.ProductRepository;
|
||||||
|
import group.goforward.ballistic.web.dto.ProductSummaryDto;
|
||||||
|
import group.goforward.ballistic.web.mapper.ProductMapper;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/products")
|
||||||
|
@CrossOrigin
|
||||||
|
public class ProductController {
|
||||||
|
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
|
public ProductController(
|
||||||
|
ProductRepository productRepository,
|
||||||
|
ProductOfferRepository productOfferRepository
|
||||||
|
) {
|
||||||
|
this.productRepository = productRepository;
|
||||||
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/gunbuilder")
|
||||||
|
@Cacheable(
|
||||||
|
value = "gunbuilderProducts",
|
||||||
|
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString())"
|
||||||
|
)
|
||||||
|
public List<ProductSummaryDto> getGunbuilderProducts(
|
||||||
|
@RequestParam(defaultValue = "AR-15") String platform,
|
||||||
|
@RequestParam(required = false, name = "partRoles") List<String> partRoles
|
||||||
|
) {
|
||||||
|
long started = System.currentTimeMillis();
|
||||||
|
System.out.println("getGunbuilderProducts: start, platform=" + platform +
|
||||||
|
", partRoles=" + (partRoles == null ? "null" : partRoles));
|
||||||
|
|
||||||
|
// 1) Load products (with brand pre-fetched)
|
||||||
|
long tProductsStart = System.currentTimeMillis();
|
||||||
|
List<Product> products;
|
||||||
|
if (partRoles == null || partRoles.isEmpty()) {
|
||||||
|
products = productRepository.findByPlatformWithBrand(platform);
|
||||||
|
} else {
|
||||||
|
products = productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles);
|
||||||
|
}
|
||||||
|
long tProductsEnd = System.currentTimeMillis();
|
||||||
|
System.out.println("getGunbuilderProducts: loaded products: " +
|
||||||
|
products.size() + " in " + (tProductsEnd - tProductsStart) + " ms");
|
||||||
|
|
||||||
|
if (products.isEmpty()) {
|
||||||
|
long took = System.currentTimeMillis() - started;
|
||||||
|
System.out.println("getGunbuilderProducts: 0 products in " + took + " ms");
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Load offers for these product IDs
|
||||||
|
long tOffersStart = System.currentTimeMillis();
|
||||||
|
List<Integer> productIds = products.stream()
|
||||||
|
.map(Product::getId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<ProductOffer> allOffers =
|
||||||
|
productOfferRepository.findByProductIdIn(productIds);
|
||||||
|
long tOffersEnd = System.currentTimeMillis();
|
||||||
|
System.out.println("getGunbuilderProducts: loaded offers: " +
|
||||||
|
allOffers.size() + " in " + (tOffersEnd - tOffersStart) + " ms");
|
||||||
|
|
||||||
|
Map<Integer, List<ProductOffer>> offersByProductId = allOffers.stream()
|
||||||
|
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||||
|
|
||||||
|
// 3) Map to DTOs with price and buyUrl
|
||||||
|
long tMapStart = System.currentTimeMillis();
|
||||||
|
List<ProductSummaryDto> result = products.stream()
|
||||||
|
.map(p -> {
|
||||||
|
List<ProductOffer> offersForProduct =
|
||||||
|
offersByProductId.getOrDefault(p.getId(), Collections.emptyList());
|
||||||
|
|
||||||
|
ProductOffer bestOffer = pickBestOffer(offersForProduct);
|
||||||
|
|
||||||
|
BigDecimal price = bestOffer != null ? bestOffer.getEffectivePrice() : null;
|
||||||
|
String buyUrl = bestOffer != null ? bestOffer.getBuyUrl() : null;
|
||||||
|
|
||||||
|
return ProductMapper.toSummary(p, price, buyUrl);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
long tMapEnd = System.currentTimeMillis();
|
||||||
|
long took = System.currentTimeMillis() - started;
|
||||||
|
|
||||||
|
System.out.println("getGunbuilderProducts: mapping to DTOs took " +
|
||||||
|
(tMapEnd - tMapStart) + " ms");
|
||||||
|
System.out.println("getGunbuilderProducts: TOTAL " + took + " ms (" +
|
||||||
|
"products=" + (tProductsEnd - tProductsStart) + " ms, " +
|
||||||
|
"offers=" + (tOffersEnd - tOffersStart) + " ms, " +
|
||||||
|
"map=" + (tMapEnd - tMapStart) + " ms)");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/offers")
|
||||||
|
public List<ProductOfferDto> getOffersForProduct(@PathVariable("id") Integer productId) {
|
||||||
|
List<ProductOffer> offers = productOfferRepository.findByProductId(productId);
|
||||||
|
|
||||||
|
return offers.stream()
|
||||||
|
.map(offer -> {
|
||||||
|
ProductOfferDto dto = new ProductOfferDto();
|
||||||
|
dto.setId(offer.getId().toString());
|
||||||
|
dto.setMerchantName(offer.getMerchant().getName());
|
||||||
|
dto.setPrice(offer.getEffectivePrice());
|
||||||
|
dto.setOriginalPrice(offer.getOriginalPrice());
|
||||||
|
dto.setInStock(Boolean.TRUE.equals(offer.getInStock()));
|
||||||
|
dto.setBuyUrl(offer.getBuyUrl());
|
||||||
|
dto.setLastUpdated(offer.getLastSeenAt());
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductOffer pickBestOffer(List<ProductOffer> offers) {
|
||||||
|
if (offers == null || offers.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right now: lowest price wins, regardless of stock (we set inStock=true on import anyway)
|
||||||
|
return offers.stream()
|
||||||
|
.filter(o -> o.getEffectivePrice() != null)
|
||||||
|
.min(Comparator.comparing(ProductOffer::getEffectivePrice))
|
||||||
|
.orElse(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package group.goforward.ballistic.controllers;
|
package group.goforward.ballistic.controllers;
|
||||||
|
|
||||||
import group.goforward.ballistic.model.Psa;
|
import group.goforward.ballistic.model.Psa;
|
||||||
import group.goforward.ballistic.service.PsaService;
|
import group.goforward.ballistic.services.impl.PsaServiceImpl;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@@ -14,10 +14,10 @@ import java.util.UUID;
|
|||||||
@RequestMapping("/api/psa")
|
@RequestMapping("/api/psa")
|
||||||
public class PsaController {
|
public class PsaController {
|
||||||
|
|
||||||
private final PsaService psaService;
|
private final PsaServiceImpl psaService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PsaController(PsaService psaService) {
|
public PsaController(PsaServiceImpl psaService) {
|
||||||
this.psaService = psaService;
|
this.psaService = psaService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package group.goforward.ballistic.controllers;
|
|||||||
import group.goforward.ballistic.ApiResponse;
|
import group.goforward.ballistic.ApiResponse;
|
||||||
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.service.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.*;
|
||||||
@@ -13,44 +14,38 @@ import java.util.List;
|
|||||||
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping()
|
@RequestMapping("/api/states")
|
||||||
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("/api/getAllStates")
|
@GetMapping("/all")
|
||||||
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("/api/getAllStatesTest")
|
@GetMapping("/{id}")
|
||||||
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("/api/getAllStatesByAbbreviation/{abbreviation}")
|
@GetMapping("/byAbbrev/{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("/api/addState")
|
@PostMapping("/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("/api/deleteState/{id}")
|
@DeleteMapping("/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 -> {
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package group.goforward.ballistic.controllers;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
import group.goforward.ballistic.repos.UserRepository;
|
||||||
|
import group.goforward.ballistic.services.UsersService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/user")
|
||||||
|
public class UserController {
|
||||||
|
private final UserRepository repo;
|
||||||
|
private final UsersService usersService;
|
||||||
|
|
||||||
|
public UserController(UserRepository repo, UsersService usersService) {
|
||||||
|
this.repo = repo;
|
||||||
|
this.usersService = usersService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/all")
|
||||||
|
public ResponseEntity<List<User>> getAllUsers() {
|
||||||
|
List<User> data = repo.findAll();
|
||||||
|
return ResponseEntity.ok(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@GetMapping("/byId/{id}")
|
||||||
|
public ResponseEntity<User> getAllStatesById(@PathVariable Integer id) {
|
||||||
|
return repo.findById(id)
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
@PostMapping("/addUser")
|
||||||
|
public ResponseEntity<User> createUser(@RequestBody User item) {
|
||||||
|
User created = usersService.save(item);
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/deleteUser/{id}")
|
||||||
|
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
|
||||||
|
return usersService.findById(id)
|
||||||
|
.map(item -> {
|
||||||
|
usersService.deleteById(id);
|
||||||
|
return ResponseEntity.noContent().<Void>build();
|
||||||
|
})
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Provides the classes necessary for the Spring Controllers for the ballistic -Builder application.
|
||||||
|
* This package includes Controllers for Spring-Boot application
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* <p>The main entry point for managing the inventory is the
|
||||||
|
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
|
||||||
|
*
|
||||||
|
* @since 1.0
|
||||||
|
* @author Don Strawsburg
|
||||||
|
* @version 1.1
|
||||||
|
*/
|
||||||
|
package group.goforward.ballistic.controllers;
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package group.goforward.ballistic.imports;
|
|
||||||
|
|
||||||
public interface MerchantFeedImportService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import the feed for a given merchant id.
|
|
||||||
*/
|
|
||||||
void importMerchantFeed(Integer merchantId);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package group.goforward.ballistic.imports;
|
|
||||||
|
|
||||||
import group.goforward.ballistic.model.Brand;
|
|
||||||
import group.goforward.ballistic.model.Merchant;
|
|
||||||
import group.goforward.ballistic.model.Product;
|
|
||||||
import group.goforward.ballistic.repos.BrandRepository;
|
|
||||||
import group.goforward.ballistic.repos.MerchantRepository;
|
|
||||||
import group.goforward.ballistic.repos.ProductRepository;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
|
||||||
|
|
||||||
|
|
||||||
@Service
|
|
||||||
@Transactional
|
|
||||||
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
|
||||||
|
|
||||||
private final MerchantRepository merchantRepository;
|
|
||||||
private final BrandRepository brandRepository;
|
|
||||||
private final ProductRepository productRepository;
|
|
||||||
|
|
||||||
public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository,
|
|
||||||
BrandRepository brandRepository,
|
|
||||||
ProductRepository productRepository) {
|
|
||||||
this.merchantRepository = merchantRepository;
|
|
||||||
this.brandRepository = brandRepository;
|
|
||||||
this.productRepository = productRepository;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void importMerchantFeed(Integer merchantId) {
|
|
||||||
System.out.println("IMPORT >>> importMerchantFeed(" + merchantId + ")");
|
|
||||||
|
|
||||||
Merchant merchant = merchantRepository.findById(merchantId)
|
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
|
||||||
|
|
||||||
// For now, just pick a brand to prove inserts work.
|
|
||||||
Brand brand = brandRepository.findByNameIgnoreCase("Aero Precision")
|
|
||||||
.orElseThrow(() -> new IllegalStateException("Brand 'Aero Precision' not found"));
|
|
||||||
|
|
||||||
// Fake a single row – we’ll swap this for real CSV parsing once the plumbing works
|
|
||||||
MerchantFeedRow row = new MerchantFeedRow(
|
|
||||||
"TEST-SKU-001",
|
|
||||||
"APPG100002",
|
|
||||||
brand.getName(),
|
|
||||||
"Test Product From Import",
|
|
||||||
null, null, null, null, null,
|
|
||||||
null, null, null, null, null,
|
|
||||||
null, null,
|
|
||||||
null, null, null, null, null, null, null, null
|
|
||||||
);
|
|
||||||
|
|
||||||
Product p = createProduct(brand, row);
|
|
||||||
System.out.println("IMPORT >>> created product id=" + p.getId()
|
|
||||||
+ ", name=" + p.getName()
|
|
||||||
+ ", merchant=" + merchant.getName());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Product createProduct(Brand brand, MerchantFeedRow row) {
|
|
||||||
System.out.println("IMPORT >>> createProduct brand=" + brand.getName()
|
|
||||||
+ ", sku=" + row.sku()
|
|
||||||
+ ", productName=" + row.productName());
|
|
||||||
|
|
||||||
Product p = new Product();
|
|
||||||
p.setBrand(brand);
|
|
||||||
|
|
||||||
String name = row.productName();
|
|
||||||
if (name == null || name.isBlank()) {
|
|
||||||
name = row.sku();
|
|
||||||
}
|
|
||||||
if (name == null || name.isBlank()) {
|
|
||||||
name = "Unknown Product";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set required fields: name and slug
|
|
||||||
p.setName(name);
|
|
||||||
|
|
||||||
// Generate a simple slug from the name (fallback to SKU if needed)
|
|
||||||
String baseForSlug = name;
|
|
||||||
if (baseForSlug == null || baseForSlug.isBlank()) {
|
|
||||||
baseForSlug = row.sku();
|
|
||||||
}
|
|
||||||
if (baseForSlug == null || baseForSlug.isBlank()) {
|
|
||||||
baseForSlug = "product-" + System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
String slug = baseForSlug
|
|
||||||
.toLowerCase()
|
|
||||||
.replaceAll("[^a-z0-9]+", "-")
|
|
||||||
.replaceAll("(^-|-$)", "");
|
|
||||||
|
|
||||||
if (slug.isBlank()) {
|
|
||||||
slug = "product-" + System.currentTimeMillis();
|
|
||||||
}
|
|
||||||
|
|
||||||
p.setSlug(slug);
|
|
||||||
|
|
||||||
if (p.getPlatform() == null || p.getPlatform().isBlank()) {
|
|
||||||
p.setPlatform("AR-15");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.getPartRole() == null || p.getPartRole().isBlank()) {
|
|
||||||
p.setPartRole("unknown");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return productRepository.save(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
) {}
|
) {}
|
||||||
@@ -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
|
||||||
) {}
|
) {}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application.
|
||||||
|
* This package includes DTO for Spring-Boot application
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* <p>The main entry point for managing the inventory is the
|
||||||
|
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
|
||||||
|
*
|
||||||
|
* @since 1.0
|
||||||
|
* @author Sean Strawsburg
|
||||||
|
* @version 1.1
|
||||||
|
*/
|
||||||
|
package group.goforward.ballistic.imports.dto;
|
||||||
@@ -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 we’re 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import java.time.OffsetDateTime;
|
|||||||
@Entity
|
@Entity
|
||||||
@Table(name = "merchants")
|
@Table(name = "merchants")
|
||||||
public class Merchant {
|
public class Merchant {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@Column(name = "id", nullable = false)
|
@Column(name = "id", nullable = false)
|
||||||
@@ -22,9 +23,18 @@ public class Merchant {
|
|||||||
@Column(name = "feed_url", nullable = false, length = Integer.MAX_VALUE)
|
@Column(name = "feed_url", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String feedUrl;
|
private String feedUrl;
|
||||||
|
|
||||||
|
@Column(name = "offer_feed_url")
|
||||||
|
private String offerFeedUrl;
|
||||||
|
|
||||||
|
@Column(name = "last_full_import_at")
|
||||||
|
private OffsetDateTime lastFullImportAt;
|
||||||
|
|
||||||
|
@Column(name = "last_offer_sync_at")
|
||||||
|
private OffsetDateTime lastOfferSyncAt;
|
||||||
|
|
||||||
@ColumnDefault("true")
|
@ColumnDefault("true")
|
||||||
@Column(name = "is_active", nullable = false)
|
@Column(name = "is_active", nullable = false)
|
||||||
private Boolean isActive = false;
|
private Boolean isActive = true;
|
||||||
|
|
||||||
@ColumnDefault("now()")
|
@ColumnDefault("now()")
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
@@ -34,6 +44,10 @@ public class Merchant {
|
|||||||
@Column(name = "updated_at", nullable = false)
|
@Column(name = "updated_at", nullable = false)
|
||||||
private OffsetDateTime updatedAt;
|
private OffsetDateTime updatedAt;
|
||||||
|
|
||||||
|
// -----------------------
|
||||||
|
// GETTERS & SETTERS
|
||||||
|
// -----------------------
|
||||||
|
|
||||||
public Integer getId() {
|
public Integer getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
@@ -66,12 +80,36 @@ public class Merchant {
|
|||||||
this.feedUrl = feedUrl;
|
this.feedUrl = feedUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getOfferFeedUrl() {
|
||||||
|
return offerFeedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOfferFeedUrl(String offerFeedUrl) {
|
||||||
|
this.offerFeedUrl = offerFeedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getLastFullImportAt() {
|
||||||
|
return lastFullImportAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastFullImportAt(OffsetDateTime lastFullImportAt) {
|
||||||
|
this.lastFullImportAt = lastFullImportAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getLastOfferSyncAt() {
|
||||||
|
return lastOfferSyncAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) {
|
||||||
|
this.lastOfferSyncAt = lastOfferSyncAt;
|
||||||
|
}
|
||||||
|
|
||||||
public Boolean getIsActive() {
|
public Boolean getIsActive() {
|
||||||
return isActive;
|
return isActive;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setIsActive(Boolean isActive) {
|
public void setIsActive(Boolean active) {
|
||||||
this.isActive = isActive;
|
this.isActive = active;
|
||||||
}
|
}
|
||||||
|
|
||||||
public OffsetDateTime getCreatedAt() {
|
public OffsetDateTime getCreatedAt() {
|
||||||
@@ -89,5 +127,4 @@ public class Merchant {
|
|||||||
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||||
this.updatedAt = updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package group.goforward.ballistic.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.ProductConfiguration;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "merchant_category_mappings",
|
||||||
|
uniqueConstraints = @UniqueConstraint(
|
||||||
|
name = "uq_merchant_category",
|
||||||
|
columnNames = { "merchant_id", "raw_category" }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
public class MerchantCategoryMapping {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL
|
||||||
|
@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", nullable = false, length = 512)
|
||||||
|
private String rawCategory;
|
||||||
|
|
||||||
|
@Column(name = "mapped_part_role", length = 128)
|
||||||
|
private String mappedPartRole; // e.g. "upper-receiver", "barrel"
|
||||||
|
|
||||||
|
@Column(name = "mapped_configuration")
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private ProductConfiguration mappedConfiguration;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false)
|
||||||
|
private OffsetDateTime createdAt = OffsetDateTime.now();
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt = OffsetDateTime.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 getRawCategory() {
|
||||||
|
return rawCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRawCategory(String rawCategory) {
|
||||||
|
this.rawCategory = rawCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMappedPartRole() {
|
||||||
|
return mappedPartRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMappedPartRole(String mappedPartRole) {
|
||||||
|
this.mappedPartRole = mappedPartRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductConfiguration getMappedConfiguration() {
|
||||||
|
return mappedConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMappedConfiguration(ProductConfiguration mappedConfiguration) {
|
||||||
|
this.mappedConfiguration = mappedConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,36 @@ 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;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "products")
|
@Table(name = "products")
|
||||||
|
@NamedQuery(name="Products.findByPlatformWithBrand", query= "" +
|
||||||
|
"SELECT p FROM Product p" +
|
||||||
|
" JOIN FETCH p.brand b" +
|
||||||
|
" WHERE p.platform = :platform" +
|
||||||
|
" AND p.deletedAt IS NULL")
|
||||||
|
|
||||||
|
@NamedQuery(name="Product.findByPlatformAndPartRoleInWithBrand", query= "" +
|
||||||
|
"SELECT p FROM Product p JOIN FETCH p.brand b" +
|
||||||
|
" WHERE p.platform = :platform" +
|
||||||
|
" AND p.partRole IN :roles" +
|
||||||
|
" AND p.deletedAt IS NULL")
|
||||||
|
|
||||||
|
@NamedQuery(name="Product.findProductsbyBrandByOffers", 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")
|
||||||
|
|
||||||
public class Product {
|
public class Product {
|
||||||
|
|
||||||
@Id
|
@Id
|
||||||
@@ -38,6 +65,10 @@ public class Product {
|
|||||||
@Column(name = "part_role")
|
@Column(name = "part_role")
|
||||||
private String partRole;
|
private String partRole;
|
||||||
|
|
||||||
|
@Column(name = "configuration")
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
private ProductConfiguration configuration;
|
||||||
|
|
||||||
@Column(name = "short_description")
|
@Column(name = "short_description")
|
||||||
private String shortDescription;
|
private String shortDescription;
|
||||||
|
|
||||||
@@ -55,6 +86,23 @@ public class Product {
|
|||||||
|
|
||||||
@Column(name = "deleted_at")
|
@Column(name = "deleted_at")
|
||||||
private Instant deletedAt;
|
private Instant deletedAt;
|
||||||
|
|
||||||
|
@Column(name = "raw_category_key")
|
||||||
|
private String rawCategoryKey;
|
||||||
|
|
||||||
|
@Column(name = "platform_locked", nullable = 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 ---
|
||||||
|
|
||||||
@@ -77,6 +125,14 @@ public class Product {
|
|||||||
updatedAt = Instant.now();
|
updatedAt = Instant.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getRawCategoryKey() {
|
||||||
|
return rawCategoryKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRawCategoryKey(String rawCategoryKey) {
|
||||||
|
this.rawCategoryKey = rawCategoryKey;
|
||||||
|
}
|
||||||
|
|
||||||
// --- getters & setters ---
|
// --- getters & setters ---
|
||||||
|
|
||||||
public Integer getId() {
|
public Integer getId() {
|
||||||
@@ -198,4 +254,57 @@ public class Product {
|
|||||||
public void setDeletedAt(Instant deletedAt) {
|
public void setDeletedAt(Instant deletedAt) {
|
||||||
this.deletedAt = deletedAt;
|
this.deletedAt = deletedAt;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
public Boolean getPlatformLocked() {
|
||||||
|
return platformLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPlatformLocked(Boolean platformLocked) {
|
||||||
|
this.platformLocked = platformLocked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductConfiguration getConfiguration() {
|
||||||
|
return configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setConfiguration(ProductConfiguration 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package group.goforward.ballistic.model;
|
||||||
|
|
||||||
|
public enum ProductConfiguration {
|
||||||
|
STRIPPED, // bare receiver / component
|
||||||
|
ASSEMBLED, // built up but not fully complete
|
||||||
|
BARRELED, // upper + barrel + gas system, no BCG/CH
|
||||||
|
COMPLETE, // full assembly ready to run
|
||||||
|
KIT, // collection of parts (LPK, trigger kits, etc.)
|
||||||
|
OTHER // fallback / unknown
|
||||||
|
}
|
||||||
@@ -7,15 +7,15 @@ 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.AUTO)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@Column(name = "id", nullable = false)
|
@Column(name = "id", nullable = false)
|
||||||
private UUID id;
|
private Integer id;
|
||||||
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
@OnDelete(action = OnDeleteAction.CASCADE)
|
@OnDelete(action = OnDeleteAction.CASCADE)
|
||||||
@@ -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,11 +60,15 @@ public class ProductOffer {
|
|||||||
@Column(name = "first_seen_at", nullable = false)
|
@Column(name = "first_seen_at", nullable = false)
|
||||||
private OffsetDateTime firstSeenAt;
|
private OffsetDateTime firstSeenAt;
|
||||||
|
|
||||||
public UUID getId() {
|
// -----------------------------------------------------
|
||||||
|
// Getters & setters
|
||||||
|
// -----------------------------------------------------
|
||||||
|
|
||||||
|
public Integer getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setId(UUID id) {
|
public void setId(Integer id) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,4 +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() {
|
||||||
|
if (price != null && originalPrice != null && price.compareTo(originalPrice) < 0) {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
return price != null ? price : originalPrice;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,298 +1,222 @@
|
|||||||
package group.goforward.ballistic.model;
|
package group.goforward.ballistic.model;
|
||||||
|
|
||||||
import jakarta.persistence.Column;
|
import jakarta.persistence.*;
|
||||||
import jakarta.persistence.Entity;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import jakarta.persistence.Id;
|
import org.hibernate.annotations.ColumnDefault;
|
||||||
import jakarta.persistence.Table;
|
|
||||||
import org.hibernate.annotations.ColumnDefault;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDate;
|
@Entity
|
||||||
import java.util.UUID;
|
@Table(name = "users")
|
||||||
|
public class User {
|
||||||
@Entity
|
|
||||||
@Table(name = "users")
|
@Id
|
||||||
public class User {
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@Id
|
@Column(name = "id", nullable = false)
|
||||||
@Column(name = "id", nullable = false, length = 21)
|
private Integer id;
|
||||||
private String id;
|
|
||||||
|
@NotNull
|
||||||
@Column(name = "name", length = Integer.MAX_VALUE)
|
@ColumnDefault("gen_random_uuid()")
|
||||||
private String name;
|
@Column(name = "uuid", nullable = false)
|
||||||
|
private UUID uuid;
|
||||||
@Column(name = "username", length = 50)
|
|
||||||
private String username;
|
@NotNull
|
||||||
|
@Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
|
||||||
@Column(name = "email", nullable = false)
|
private String email;
|
||||||
private String email;
|
|
||||||
|
@NotNull
|
||||||
@Column(name = "first_name", length = 50)
|
@Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
|
||||||
private String firstName;
|
private String passwordHash;
|
||||||
|
|
||||||
@Column(name = "last_name", length = 50)
|
@Column(name = "display_name", length = Integer.MAX_VALUE)
|
||||||
private String lastName;
|
private String displayName;
|
||||||
|
|
||||||
@Column(name = "full_name", length = 50)
|
@NotNull
|
||||||
private String fullName;
|
@ColumnDefault("'USER'")
|
||||||
|
@Column(name = "role", nullable = false, length = Integer.MAX_VALUE)
|
||||||
@Column(name = "profile_picture")
|
private String role;
|
||||||
private String profilePicture;
|
|
||||||
|
@NotNull
|
||||||
@Column(name = "image", length = Integer.MAX_VALUE)
|
@ColumnDefault("true")
|
||||||
private String image;
|
@Column(name = "is_active", nullable = false)
|
||||||
|
private boolean isActive = true;
|
||||||
@Column(name = "date_of_birth")
|
|
||||||
private LocalDate dateOfBirth;
|
@NotNull
|
||||||
|
@ColumnDefault("now()")
|
||||||
@Column(name = "phone_number", length = 20)
|
@Column(name = "created_at", nullable = false)
|
||||||
private String phoneNumber;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
@ColumnDefault("CURRENT_TIMESTAMP")
|
@NotNull
|
||||||
@Column(name = "created_at")
|
@ColumnDefault("now()")
|
||||||
private Instant createdAt;
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private OffsetDateTime updatedAt;
|
||||||
@ColumnDefault("CURRENT_TIMESTAMP")
|
|
||||||
@Column(name = "updated_at")
|
@Column(name = "deleted_at")
|
||||||
private Instant updatedAt;
|
private OffsetDateTime deletedAt;
|
||||||
|
|
||||||
@ColumnDefault("false")
|
// NEW FIELDS
|
||||||
@Column(name = "is_admin")
|
|
||||||
private Boolean isAdmin;
|
@Column(name = "email_verified_at")
|
||||||
|
private OffsetDateTime emailVerifiedAt;
|
||||||
@Column(name = "last_login")
|
|
||||||
private Instant lastLogin;
|
@Column(name = "verification_token", length = Integer.MAX_VALUE)
|
||||||
|
private String verificationToken;
|
||||||
@ColumnDefault("false")
|
|
||||||
@Column(name = "email_verified", nullable = false)
|
@Column(name = "reset_password_token", length = Integer.MAX_VALUE)
|
||||||
private Boolean emailVerified = false;
|
private String resetPasswordToken;
|
||||||
|
|
||||||
@ColumnDefault("'public'")
|
@Column(name = "reset_password_expires_at")
|
||||||
@Column(name = "build_privacy_setting", length = Integer.MAX_VALUE)
|
private OffsetDateTime resetPasswordExpiresAt;
|
||||||
private String buildPrivacySetting;
|
|
||||||
|
@Column(name = "last_login_at")
|
||||||
@ColumnDefault("gen_random_uuid()")
|
private OffsetDateTime lastLoginAt;
|
||||||
@Column(name = "uuid")
|
|
||||||
private UUID uuid;
|
@ColumnDefault("0")
|
||||||
|
@Column(name = "login_count", nullable = false)
|
||||||
@Column(name = "discord_id")
|
private Integer loginCount = 0;
|
||||||
private String discordId;
|
|
||||||
|
// --- Getters / setters ---
|
||||||
@Column(name = "hashed_password")
|
|
||||||
private String hashedPassword;
|
public Integer getId() {
|
||||||
|
return id;
|
||||||
@Column(name = "avatar")
|
}
|
||||||
private String avatar;
|
|
||||||
|
public void setId(Integer id) {
|
||||||
@Column(name = "stripe_subscription_id", length = 191)
|
this.id = id;
|
||||||
private String stripeSubscriptionId;
|
}
|
||||||
|
|
||||||
@Column(name = "stripe_price_id", length = 191)
|
public UUID getUuid() {
|
||||||
private String stripePriceId;
|
return uuid;
|
||||||
|
}
|
||||||
@Column(name = "stripe_customer_id", length = 191)
|
|
||||||
private String stripeCustomerId;
|
public void setUuid(UUID uuid) {
|
||||||
|
this.uuid = uuid;
|
||||||
@Column(name = "stripe_current_period_end")
|
}
|
||||||
private Instant stripeCurrentPeriodEnd;
|
|
||||||
|
public String getEmail() {
|
||||||
public String getId() {
|
return email;
|
||||||
return id;
|
}
|
||||||
}
|
|
||||||
|
public void setEmail(String email) {
|
||||||
public void setId(String id) {
|
this.email = email;
|
||||||
this.id = id;
|
}
|
||||||
}
|
|
||||||
|
public String getPasswordHash() {
|
||||||
public String getName() {
|
return passwordHash;
|
||||||
return name;
|
}
|
||||||
}
|
|
||||||
|
public void setPasswordHash(String passwordHash) {
|
||||||
public void setName(String name) {
|
this.passwordHash = passwordHash;
|
||||||
this.name = name;
|
}
|
||||||
}
|
|
||||||
|
public String getDisplayName() {
|
||||||
public String getUsername() {
|
return displayName;
|
||||||
return username;
|
}
|
||||||
}
|
|
||||||
|
public void setDisplayName(String displayName) {
|
||||||
public void setUsername(String username) {
|
this.displayName = displayName;
|
||||||
this.username = username;
|
}
|
||||||
}
|
|
||||||
|
public String getRole() {
|
||||||
public String getEmail() {
|
return role;
|
||||||
return email;
|
}
|
||||||
}
|
|
||||||
|
public void setRole(String role) {
|
||||||
public void setEmail(String email) {
|
this.role = role;
|
||||||
this.email = email;
|
}
|
||||||
}
|
|
||||||
|
public boolean getIsActive() {
|
||||||
public String getFirstName() {
|
return isActive;
|
||||||
return firstName;
|
}
|
||||||
}
|
|
||||||
|
public void setIsActive(boolean active) {
|
||||||
public void setFirstName(String firstName) {
|
isActive = active;
|
||||||
this.firstName = firstName;
|
}
|
||||||
}
|
|
||||||
|
public OffsetDateTime getCreatedAt() {
|
||||||
public String getLastName() {
|
return createdAt;
|
||||||
return lastName;
|
}
|
||||||
}
|
|
||||||
|
public void setCreatedAt(OffsetDateTime createdAt) {
|
||||||
public void setLastName(String lastName) {
|
this.createdAt = createdAt;
|
||||||
this.lastName = lastName;
|
}
|
||||||
}
|
|
||||||
|
public OffsetDateTime getUpdatedAt() {
|
||||||
public String getFullName() {
|
return updatedAt;
|
||||||
return fullName;
|
}
|
||||||
}
|
|
||||||
|
public void setUpdatedAt(OffsetDateTime updatedAt) {
|
||||||
public void setFullName(String fullName) {
|
this.updatedAt = updatedAt;
|
||||||
this.fullName = fullName;
|
}
|
||||||
}
|
|
||||||
|
public OffsetDateTime getDeletedAt() {
|
||||||
public String getProfilePicture() {
|
return deletedAt;
|
||||||
return profilePicture;
|
}
|
||||||
}
|
|
||||||
|
public void setDeletedAt(OffsetDateTime deletedAt) {
|
||||||
public void setProfilePicture(String profilePicture) {
|
this.deletedAt = deletedAt;
|
||||||
this.profilePicture = profilePicture;
|
}
|
||||||
}
|
|
||||||
|
public OffsetDateTime getEmailVerifiedAt() {
|
||||||
public String getImage() {
|
return emailVerifiedAt;
|
||||||
return image;
|
}
|
||||||
}
|
|
||||||
|
public void setEmailVerifiedAt(OffsetDateTime emailVerifiedAt) {
|
||||||
public void setImage(String image) {
|
this.emailVerifiedAt = emailVerifiedAt;
|
||||||
this.image = image;
|
}
|
||||||
}
|
|
||||||
|
public String getVerificationToken() {
|
||||||
public LocalDate getDateOfBirth() {
|
return verificationToken;
|
||||||
return dateOfBirth;
|
}
|
||||||
}
|
|
||||||
|
public void setVerificationToken(String verificationToken) {
|
||||||
public void setDateOfBirth(LocalDate dateOfBirth) {
|
this.verificationToken = verificationToken;
|
||||||
this.dateOfBirth = dateOfBirth;
|
}
|
||||||
}
|
|
||||||
|
public String getResetPasswordToken() {
|
||||||
public String getPhoneNumber() {
|
return resetPasswordToken;
|
||||||
return phoneNumber;
|
}
|
||||||
}
|
|
||||||
|
public void setResetPasswordToken(String resetPasswordToken) {
|
||||||
public void setPhoneNumber(String phoneNumber) {
|
this.resetPasswordToken = resetPasswordToken;
|
||||||
this.phoneNumber = phoneNumber;
|
}
|
||||||
}
|
|
||||||
|
public OffsetDateTime getResetPasswordExpiresAt() {
|
||||||
public Instant getCreatedAt() {
|
return resetPasswordExpiresAt;
|
||||||
return createdAt;
|
}
|
||||||
}
|
|
||||||
|
public void setResetPasswordExpiresAt(OffsetDateTime resetPasswordExpiresAt) {
|
||||||
public void setCreatedAt(Instant createdAt) {
|
this.resetPasswordExpiresAt = resetPasswordExpiresAt;
|
||||||
this.createdAt = createdAt;
|
}
|
||||||
}
|
|
||||||
|
public OffsetDateTime getLastLoginAt() {
|
||||||
public Instant getUpdatedAt() {
|
return lastLoginAt;
|
||||||
return updatedAt;
|
}
|
||||||
}
|
|
||||||
|
public void setLastLoginAt(OffsetDateTime lastLoginAt) {
|
||||||
public void setUpdatedAt(Instant updatedAt) {
|
this.lastLoginAt = lastLoginAt;
|
||||||
this.updatedAt = updatedAt;
|
}
|
||||||
}
|
|
||||||
|
public Integer getLoginCount() {
|
||||||
public Boolean getIsAdmin() {
|
return loginCount;
|
||||||
return isAdmin;
|
}
|
||||||
}
|
|
||||||
|
public void setLoginCount(Integer loginCount) {
|
||||||
public void setIsAdmin(Boolean isAdmin) {
|
this.loginCount = loginCount;
|
||||||
this.isAdmin = isAdmin;
|
}
|
||||||
}
|
|
||||||
|
// convenience helpers
|
||||||
public Instant getLastLogin() {
|
|
||||||
return lastLogin;
|
@Transient
|
||||||
}
|
public boolean isEmailVerified() {
|
||||||
|
return emailVerifiedAt != null;
|
||||||
public void setLastLogin(Instant lastLogin) {
|
}
|
||||||
this.lastLogin = lastLogin;
|
|
||||||
}
|
public void incrementLoginCount() {
|
||||||
|
if (loginCount == null) {
|
||||||
public Boolean getEmailVerified() {
|
loginCount = 0;
|
||||||
return emailVerified;
|
}
|
||||||
}
|
loginCount++;
|
||||||
|
}
|
||||||
public void setEmailVerified(Boolean emailVerified) {
|
|
||||||
this.emailVerified = emailVerified;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getBuildPrivacySetting() {
|
|
||||||
return buildPrivacySetting;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setBuildPrivacySetting(String buildPrivacySetting) {
|
|
||||||
this.buildPrivacySetting = buildPrivacySetting;
|
|
||||||
}
|
|
||||||
|
|
||||||
public UUID getUuid() {
|
|
||||||
return uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setUuid(UUID uuid) {
|
|
||||||
this.uuid = uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getDiscordId() {
|
|
||||||
return discordId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setDiscordId(String discordId) {
|
|
||||||
this.discordId = discordId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getHashedPassword() {
|
|
||||||
return hashedPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setHashedPassword(String hashedPassword) {
|
|
||||||
this.hashedPassword = hashedPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getAvatar() {
|
|
||||||
return avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAvatar(String avatar) {
|
|
||||||
this.avatar = avatar;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getStripeSubscriptionId() {
|
|
||||||
return stripeSubscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStripeSubscriptionId(String stripeSubscriptionId) {
|
|
||||||
this.stripeSubscriptionId = stripeSubscriptionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getStripePriceId() {
|
|
||||||
return stripePriceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStripePriceId(String stripePriceId) {
|
|
||||||
this.stripePriceId = stripePriceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public String getStripeCustomerId() {
|
|
||||||
return stripeCustomerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStripeCustomerId(String stripeCustomerId) {
|
|
||||||
this.stripeCustomerId = stripeCustomerId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Instant getStripeCurrentPeriodEnd() {
|
|
||||||
return stripeCurrentPeriodEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setStripeCurrentPeriodEnd(Instant stripeCurrentPeriodEnd) {
|
|
||||||
this.stripeCurrentPeriodEnd = stripeCurrentPeriodEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package group.goforward.ballistic.model;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package group.goforward.ballistic.model;
|
package group.goforward.ballistic.repos;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
package group.goforward.ballistic.repos;
|
package group.goforward.ballistic.repos;
|
||||||
|
|
||||||
import group.goforward.ballistic.model.AffiliateCategoryMap;
|
import group.goforward.ballistic.model.CategoryMapping;
|
||||||
|
import group.goforward.ballistic.model.Merchant;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
public interface CategoryMappingRepository extends JpaRepository<AffiliateCategoryMap, Integer> {
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CategoryMappingRepository extends JpaRepository<CategoryMapping, Integer> {
|
||||||
|
|
||||||
|
// All mappings for a merchant, ordered nicely
|
||||||
|
List<CategoryMapping> findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
|
||||||
|
|
||||||
|
// Merchants that actually have mappings (for the dropdown)
|
||||||
|
@Query("""
|
||||||
|
select distinct cm.merchant
|
||||||
|
from CategoryMapping cm
|
||||||
|
order by cm.merchant.name asc
|
||||||
|
""")
|
||||||
|
List<Merchant> findDistinctMerchantsWithMappings();
|
||||||
}
|
}
|
||||||
@@ -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> {
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package group.goforward.ballistic.repos;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.MerchantCategoryMapping;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface MerchantCategoryMappingRepository
|
||||||
|
extends JpaRepository<MerchantCategoryMapping, Integer> {
|
||||||
|
|
||||||
|
Optional<MerchantCategoryMapping> findByMerchantIdAndRawCategoryIgnoreCase(
|
||||||
|
Integer merchantId,
|
||||||
|
String rawCategory
|
||||||
|
);
|
||||||
|
|
||||||
|
List<MerchantCategoryMapping> findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
|
||||||
|
}
|
||||||
@@ -1,7 +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;
|
||||||
|
|
||||||
public interface MerchantRepository extends JpaRepository<Merchant, Integer> {
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface MerchantRepository extends JpaRepository<Merchant, Integer> {
|
||||||
|
|
||||||
|
Optional<Merchant> findByNameIgnoreCase(String name);
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,13 @@ package group.goforward.ballistic.repos;
|
|||||||
|
|
||||||
import group.goforward.ballistic.model.PartCategory;
|
import group.goforward.ballistic.model.PartCategory;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
|
public interface PartCategoryRepository extends JpaRepository<PartCategory, Integer> {
|
||||||
|
|
||||||
Optional<PartCategory> findBySlug(String slug);
|
Optional<PartCategory> findBySlug(String slug);
|
||||||
|
|
||||||
|
List<PartCategory> findAllByOrderByGroupNameAscSortOrderAscNameAsc();
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
}
|
}
|
||||||
@@ -1,14 +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 group.goforward.ballistic.model.Product;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import group.goforward.ballistic.model.Merchant;
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
|
||||||
|
public interface ProductOfferRepository extends JpaRepository<ProductOffer, Integer> {
|
||||||
public interface ProductOfferRepository extends JpaRepository<ProductOffer, UUID> {
|
|
||||||
Optional<ProductOffer> findByMerchantAndAvantlinkProductId(Merchant merchant, String avantlinkProductId);
|
List<ProductOffer> findByProductId(Integer productId);
|
||||||
List<ProductOffer> findByProductAndInStockTrueOrderByPriceAsc(Product product);
|
|
||||||
|
// Used by the /api/products/gunbuilder endpoint
|
||||||
|
List<ProductOffer> findByProductIdIn(Collection<Integer> productIds);
|
||||||
|
|
||||||
|
// Unique offer lookup for importer upsert
|
||||||
|
Optional<ProductOffer> findByMerchantIdAndAvantlinkProductId(
|
||||||
|
Integer merchantId,
|
||||||
|
String avantlinkProductId
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,65 @@
|
|||||||
package group.goforward.ballistic.repos;
|
package group.goforward.ballistic.repos;
|
||||||
|
|
||||||
import group.goforward.ballistic.model.Product;
|
|
||||||
import group.goforward.ballistic.model.Brand;
|
import group.goforward.ballistic.model.Brand;
|
||||||
|
import group.goforward.ballistic.model.Product;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface ProductRepository extends JpaRepository<Product, Integer> {
|
public interface ProductRepository extends JpaRepository<Product, Integer> {
|
||||||
|
|
||||||
Optional<Product> findByUuid(UUID uuid);
|
// -------------------------------------------------
|
||||||
|
// Used by MerchantFeedImportServiceImpl
|
||||||
|
// -------------------------------------------------
|
||||||
|
|
||||||
Optional<Product> findByBrandAndMpn(Brand brand, String mpn);
|
List<Product> findAllByBrandAndMpn(Brand brand, String mpn);
|
||||||
|
|
||||||
Optional<Product> findByBrandAndUpc(Brand brand, String upc);
|
List<Product> findAllByBrandAndUpc(Brand brand, String upc);
|
||||||
|
|
||||||
|
boolean existsBySlug(String slug);
|
||||||
|
|
||||||
|
// -------------------------------------------------
|
||||||
|
// Used by ProductController for platform views
|
||||||
|
// -------------------------------------------------
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT p
|
||||||
|
FROM Product p
|
||||||
|
JOIN FETCH p.brand b
|
||||||
|
WHERE p.platform = :platform
|
||||||
|
AND p.deletedAt IS NULL
|
||||||
|
""")
|
||||||
|
List<Product> findByPlatformWithBrand(@Param("platform") String platform);
|
||||||
|
|
||||||
|
@Query(name="Products.findByPlatformWithBrand")
|
||||||
|
List<Product> findByPlatformWithBrandNQ(@Param("platform") String platform);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT p
|
||||||
|
FROM Product p
|
||||||
|
JOIN FETCH p.brand b
|
||||||
|
WHERE p.platform = :platform
|
||||||
|
AND p.partRole IN :roles
|
||||||
|
AND p.deletedAt IS NULL
|
||||||
|
""")
|
||||||
|
List<Product> findByPlatformAndPartRoleInWithBrand(
|
||||||
|
@Param("platform") String platform,
|
||||||
|
@Param("roles") List<String> roles
|
||||||
|
);
|
||||||
|
|
||||||
|
// -------------------------------------------------
|
||||||
|
// Used by Gunbuilder service (if you wired this)
|
||||||
|
// -------------------------------------------------
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT DISTINCT p
|
||||||
|
FROM Product p
|
||||||
|
LEFT JOIN FETCH p.brand b
|
||||||
|
LEFT JOIN FETCH p.offers o
|
||||||
|
WHERE p.platform = :platform
|
||||||
|
AND p.deletedAt IS NULL
|
||||||
|
""")
|
||||||
|
List<Product> findSomethingForGunbuilder(@Param("platform") String platform);
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,15 @@ package group.goforward.ballistic.repos;
|
|||||||
|
|
||||||
import group.goforward.ballistic.model.User;
|
import group.goforward.ballistic.model.User;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface UserRepository extends JpaRepository<User, Integer> {
|
public interface UserRepository extends JpaRepository<User, Integer> {
|
||||||
Optional<User> findByEmail(String email);
|
|
||||||
|
Optional<User> findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||||
|
|
||||||
|
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
|
||||||
|
|
||||||
Optional<User> findByUuid(UUID uuid);
|
Optional<User> findByUuid(UUID uuid);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Provides the classes necessary for the Spring Repository for the ballistic -Builder application.
|
||||||
|
* This package includes Repository for Spring-Boot application
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* <p>The main entry point for managing the inventory is the
|
||||||
|
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
|
||||||
|
*
|
||||||
|
* @since 1.0
|
||||||
|
* @author Sean Strawsburg
|
||||||
|
* @version 1.1
|
||||||
|
*/
|
||||||
|
package group.goforward.ballistic.repos;
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package group.goforward.ballistic.security;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class CustomUserDetails implements UserDetails {
|
||||||
|
|
||||||
|
private final User user;
|
||||||
|
private final List<GrantedAuthority> authorities;
|
||||||
|
|
||||||
|
public CustomUserDetails(User user) {
|
||||||
|
this.user = user;
|
||||||
|
this.authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public User getUser() {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||||
|
return authorities;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPassword() {
|
||||||
|
return user.getPasswordHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getUsername() {
|
||||||
|
return user.getEmail();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonExpired() {
|
||||||
|
return user.getDeletedAt() == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isAccountNonLocked() {
|
||||||
|
return user.getIsActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isCredentialsNonExpired() {
|
||||||
|
return user.getDeletedAt() == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return user.getIsActive() && user.getDeletedAt() == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package group.goforward.ballistic.security;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
import group.goforward.ballistic.repos.UserRepository;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class CustomUserDetailsService implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository users;
|
||||||
|
|
||||||
|
public CustomUserDetailsService(UserRepository users) {
|
||||||
|
this.users = users;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
|
User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||||
|
return new CustomUserDetails(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package group.goforward.ballistic.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void commence(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
AuthenticationException authException
|
||||||
|
) throws IOException, ServletException {
|
||||||
|
// Simple JSON 401 response
|
||||||
|
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().write("{\"error\":\"Unauthorized\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package group.goforward.ballistic.security;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
import group.goforward.ballistic.repos.UserRepository;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
|
||||||
|
this.jwtService = jwtService;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain
|
||||||
|
) throws ServletException, IOException {
|
||||||
|
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
|
if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = authHeader.substring(7);
|
||||||
|
|
||||||
|
if (!jwtService.isTokenValid(token)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID userUuid = jwtService.extractUserUuid(token);
|
||||||
|
|
||||||
|
if (userUuid == null || SecurityContextHolder.getContext().getAuthentication() != null) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = userRepository.findByUuid(userUuid)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (user == null || !user.getIsActive()) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CustomUserDetails userDetails = new CustomUserDetails(user);
|
||||||
|
|
||||||
|
UsernamePasswordAuthenticationToken authToken =
|
||||||
|
new UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails,
|
||||||
|
null,
|
||||||
|
userDetails.getAuthorities()
|
||||||
|
);
|
||||||
|
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package group.goforward.ballistic.security;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.JwtException;
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.security.Key;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
private final Key key;
|
||||||
|
private final long accessTokenMinutes;
|
||||||
|
|
||||||
|
public JwtService(
|
||||||
|
@Value("${security.jwt.secret}") String secret,
|
||||||
|
@Value("${security.jwt.access-token-minutes:60}") long accessTokenMinutes
|
||||||
|
) {
|
||||||
|
this.key = Keys.hmacShaKeyFor(secret.getBytes());
|
||||||
|
this.accessTokenMinutes = accessTokenMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(User user) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES);
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.setSubject(user.getUuid().toString())
|
||||||
|
.setIssuedAt(Date.from(now))
|
||||||
|
.setExpiration(Date.from(expiry))
|
||||||
|
.addClaims(Map.of(
|
||||||
|
"email", user.getEmail(),
|
||||||
|
"role", user.getRole(),
|
||||||
|
"displayName", user.getDisplayName()
|
||||||
|
))
|
||||||
|
.signWith(key, SignatureAlgorithm.HS256)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID extractUserUuid(String token) {
|
||||||
|
Claims claims = parseClaims(token);
|
||||||
|
return UUID.fromString(claims.getSubject());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenValid(String token) {
|
||||||
|
try {
|
||||||
|
parseClaims(token);
|
||||||
|
return true;
|
||||||
|
} catch (JwtException | IllegalArgumentException ex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Claims parseClaims(String token) {
|
||||||
|
return Jwts.parserBuilder()
|
||||||
|
.setSigningKey(key)
|
||||||
|
.build()
|
||||||
|
.parseClaimsJws(token)
|
||||||
|
.getBody();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package group.goforward.ballistic.services;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.Merchant;
|
||||||
|
import group.goforward.ballistic.model.MerchantCategoryMapping;
|
||||||
|
import group.goforward.ballistic.model.ProductConfiguration;
|
||||||
|
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
|
||||||
|
import jakarta.transaction.Transactional;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MerchantCategoryMappingService {
|
||||||
|
|
||||||
|
private final MerchantCategoryMappingRepository mappingRepository;
|
||||||
|
|
||||||
|
public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) {
|
||||||
|
this.mappingRepository = mappingRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<MerchantCategoryMapping> findByMerchant(Integer merchantId) {
|
||||||
|
return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve (or create) a mapping row for this merchant + raw category.
|
||||||
|
* - 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.
|
||||||
|
*
|
||||||
|
* The importer can then:
|
||||||
|
* - skip rows where mappedPartRole is still null
|
||||||
|
* - use mappedConfiguration if present
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) {
|
||||||
|
if (rawCategory == null || rawCategory.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String trimmed = rawCategory.trim();
|
||||||
|
|
||||||
|
return mappingRepository
|
||||||
|
.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
MerchantCategoryMapping mapping = new MerchantCategoryMapping();
|
||||||
|
mapping.setMerchant(merchant);
|
||||||
|
mapping.setRawCategory(trimmed);
|
||||||
|
mapping.setMappedPartRole(null);
|
||||||
|
mapping.setMappedConfiguration(null);
|
||||||
|
return mappingRepository.save(mapping);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert mapping (admin UI).
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MerchantCategoryMapping upsertMapping(
|
||||||
|
Merchant merchant,
|
||||||
|
String rawCategory,
|
||||||
|
String mappedPartRole,
|
||||||
|
ProductConfiguration mappedConfiguration
|
||||||
|
) {
|
||||||
|
String trimmed = rawCategory.trim();
|
||||||
|
|
||||||
|
MerchantCategoryMapping mapping = mappingRepository
|
||||||
|
.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
MerchantCategoryMapping m = new MerchantCategoryMapping();
|
||||||
|
m.setMerchant(merchant);
|
||||||
|
m.setRawCategory(trimmed);
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
mapping.setMappedPartRole(
|
||||||
|
(mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
mapping.setMappedConfiguration(mappedConfiguration);
|
||||||
|
|
||||||
|
return mappingRepository.save(mapping);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Backwards-compatible overload for existing callers (e.g. controller)
|
||||||
|
* that don’t care about productConfiguration yet.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public MerchantCategoryMapping upsertMapping(
|
||||||
|
Merchant merchant,
|
||||||
|
String rawCategory,
|
||||||
|
String mappedPartRole
|
||||||
|
) {
|
||||||
|
// Delegate to the new method with `null` configuration
|
||||||
|
return upsertMapping(merchant, rawCategory, mappedPartRole, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package group.goforward.ballistic.services;
|
||||||
|
|
||||||
|
public interface MerchantFeedImportService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full product + offer import for a given merchant.
|
||||||
|
*/
|
||||||
|
void importMerchantFeed(Integer merchantId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers-only sync (price / stock) for a given merchant.
|
||||||
|
*/
|
||||||
|
void syncOffersOnly(Integer merchantId);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package group.goforward.ballistic.services;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.Psa;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface PsaService {
|
||||||
|
List<Psa> findAll();
|
||||||
|
|
||||||
|
Optional<Psa> findById(UUID id);
|
||||||
|
|
||||||
|
Psa save(Psa psa);
|
||||||
|
|
||||||
|
void deleteById(UUID id);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package group.goforward.ballistic.service;
|
package group.goforward.ballistic.services;
|
||||||
|
|
||||||
import group.goforward.ballistic.model.State;
|
import group.goforward.ballistic.model.State;
|
||||||
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package group.goforward.ballistic.services;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface UsersService {
|
||||||
|
|
||||||
|
List<User> findAll();
|
||||||
|
|
||||||
|
Optional<User> findById(Integer id);
|
||||||
|
|
||||||
|
User save(User item);
|
||||||
|
void deleteById(Integer id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
package group.goforward.ballistic.services.impl;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.io.Reader;
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.imports.MerchantFeedRow;
|
||||||
|
import group.goforward.ballistic.services.MerchantFeedImportService;
|
||||||
|
import org.apache.commons.csv.CSVFormat;
|
||||||
|
import org.apache.commons.csv.CSVParser;
|
||||||
|
import org.apache.commons.csv.CSVRecord;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.Brand;
|
||||||
|
import group.goforward.ballistic.model.Merchant;
|
||||||
|
import group.goforward.ballistic.model.Product;
|
||||||
|
import group.goforward.ballistic.repos.BrandRepository;
|
||||||
|
import group.goforward.ballistic.repos.MerchantRepository;
|
||||||
|
import group.goforward.ballistic.repos.ProductRepository;
|
||||||
|
import group.goforward.ballistic.services.MerchantCategoryMappingService;
|
||||||
|
import group.goforward.ballistic.model.MerchantCategoryMapping;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import group.goforward.ballistic.repos.ProductOfferRepository;
|
||||||
|
import group.goforward.ballistic.model.ProductOffer;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
|
||||||
|
|
||||||
|
private final MerchantRepository merchantRepository;
|
||||||
|
private final BrandRepository brandRepository;
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
private final MerchantCategoryMappingService merchantCategoryMappingService;
|
||||||
|
private final ProductOfferRepository productOfferRepository;
|
||||||
|
|
||||||
|
public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository,
|
||||||
|
BrandRepository brandRepository,
|
||||||
|
ProductRepository productRepository,
|
||||||
|
MerchantCategoryMappingService merchantCategoryMappingService,
|
||||||
|
ProductOfferRepository productOfferRepository) {
|
||||||
|
this.merchantRepository = merchantRepository;
|
||||||
|
this.brandRepository = brandRepository;
|
||||||
|
this.productRepository = productRepository;
|
||||||
|
this.merchantCategoryMappingService = merchantCategoryMappingService;
|
||||||
|
this.productOfferRepository = productOfferRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||||
|
public void importMerchantFeed(Integer merchantId) {
|
||||||
|
log.info("Starting full import for merchantId={}", merchantId);
|
||||||
|
|
||||||
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
|
||||||
|
|
||||||
|
// Read all rows from the merchant feed
|
||||||
|
List<MerchantFeedRow> rows = readFeedRowsForMerchant(merchant);
|
||||||
|
log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
|
||||||
|
|
||||||
|
for (MerchantFeedRow row : rows) {
|
||||||
|
Brand brand = resolveBrand(row);
|
||||||
|
Product p = upsertProduct(merchant, brand, row);
|
||||||
|
log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}",
|
||||||
|
p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Upsert logic
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
|
||||||
|
log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName());
|
||||||
|
|
||||||
|
String mpn = trimOrNull(row.manufacturerId());
|
||||||
|
String upc = trimOrNull(row.sku()); // placeholder until real UPC field
|
||||||
|
|
||||||
|
List<Product> candidates = Collections.emptyList();
|
||||||
|
|
||||||
|
if (mpn != null) {
|
||||||
|
candidates = productRepository.findAllByBrandAndMpn(brand, mpn);
|
||||||
|
}
|
||||||
|
if ((candidates == null || candidates.isEmpty()) && upc != null) {
|
||||||
|
candidates = productRepository.findAllByBrandAndUpc(brand, upc);
|
||||||
|
}
|
||||||
|
|
||||||
|
Product p;
|
||||||
|
boolean isNew = (candidates == null || candidates.isEmpty());
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
p = new Product();
|
||||||
|
p.setBrand(brand);
|
||||||
|
} else {
|
||||||
|
if (candidates.size() > 1) {
|
||||||
|
log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}",
|
||||||
|
brand.getName(), mpn, upc, candidates.get(0).getId());
|
||||||
|
}
|
||||||
|
p = candidates.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProductFromRow(p, merchant, row, isNew);
|
||||||
|
|
||||||
|
// Save the product first
|
||||||
|
Product saved = productRepository.save(p);
|
||||||
|
|
||||||
|
// Then upsert the offer for this row
|
||||||
|
upsertOfferFromRow(saved, merchant, row);
|
||||||
|
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
private List<Map<String, String>> fetchFeedRows(String feedUrl) {
|
||||||
|
log.info("Reading offer feed from {}", feedUrl);
|
||||||
|
|
||||||
|
List<Map<String, String>> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://"))
|
||||||
|
? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8)
|
||||||
|
: java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8);
|
||||||
|
CSVParser parser = CSVFormat.DEFAULT
|
||||||
|
.withFirstRecordAsHeader()
|
||||||
|
.withIgnoreSurroundingSpaces()
|
||||||
|
.withTrim()
|
||||||
|
.parse(reader)) {
|
||||||
|
|
||||||
|
// capture header names from the CSV
|
||||||
|
List<String> headers = new ArrayList<>(parser.getHeaderMap().keySet());
|
||||||
|
|
||||||
|
for (CSVRecord rec : parser) {
|
||||||
|
Map<String, String> row = new HashMap<>();
|
||||||
|
for (String header : headers) {
|
||||||
|
row.put(header, rec.get(header));
|
||||||
|
}
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Parsed {} offer rows from offer feed {}", rows.size(), feedUrl);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) {
|
||||||
|
// ---------- NAME ----------
|
||||||
|
String name = coalesce(
|
||||||
|
trimOrNull(row.productName()),
|
||||||
|
trimOrNull(row.shortDescription()),
|
||||||
|
trimOrNull(row.longDescription()),
|
||||||
|
trimOrNull(row.sku())
|
||||||
|
);
|
||||||
|
if (name == null) {
|
||||||
|
name = "Unknown Product";
|
||||||
|
}
|
||||||
|
p.setName(name);
|
||||||
|
|
||||||
|
// ---------- SLUG ----------
|
||||||
|
if (isNew || p.getSlug() == null || p.getSlug().isBlank()) {
|
||||||
|
String baseForSlug = coalesce(
|
||||||
|
trimOrNull(name),
|
||||||
|
trimOrNull(row.sku())
|
||||||
|
);
|
||||||
|
if (baseForSlug == null) {
|
||||||
|
baseForSlug = "product-" + System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
String slug = baseForSlug
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll("[^a-z0-9]+", "-")
|
||||||
|
.replaceAll("(^-|-$)", "");
|
||||||
|
if (slug.isBlank()) {
|
||||||
|
slug = "product-" + System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
String uniqueSlug = generateUniqueSlug(slug);
|
||||||
|
p.setSlug(uniqueSlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- DESCRIPTIONS ----------
|
||||||
|
p.setShortDescription(trimOrNull(row.shortDescription()));
|
||||||
|
p.setDescription(trimOrNull(row.longDescription()));
|
||||||
|
|
||||||
|
// ---------- IMAGE ----------
|
||||||
|
String mainImage = coalesce(
|
||||||
|
trimOrNull(row.imageUrl()),
|
||||||
|
trimOrNull(row.mediumImageUrl()),
|
||||||
|
trimOrNull(row.thumbUrl())
|
||||||
|
);
|
||||||
|
p.setMainImageUrl(mainImage);
|
||||||
|
|
||||||
|
// ---------- IDENTIFIERS ----------
|
||||||
|
String mpn = coalesce(
|
||||||
|
trimOrNull(row.manufacturerId()),
|
||||||
|
trimOrNull(row.sku())
|
||||||
|
);
|
||||||
|
p.setMpn(mpn);
|
||||||
|
|
||||||
|
// UPC placeholder
|
||||||
|
p.setUpc(null);
|
||||||
|
|
||||||
|
// ---------- PLATFORM ----------
|
||||||
|
if (isNew || !Boolean.TRUE.equals(p.getPlatformLocked())) {
|
||||||
|
String platform = inferPlatform(row);
|
||||||
|
p.setPlatform(platform != null ? platform : "AR-15");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- RAW CATEGORY KEY (for debugging / analytics) ----------
|
||||||
|
String rawCategoryKey = buildRawCategoryKey(row);
|
||||||
|
p.setRawCategoryKey(rawCategoryKey);
|
||||||
|
|
||||||
|
// ---------- PART ROLE (via category mapping, with keyword fallback) ----------
|
||||||
|
String partRole = null;
|
||||||
|
|
||||||
|
if (rawCategoryKey != null) {
|
||||||
|
// Ask the mapping service for (or to create) a mapping row
|
||||||
|
MerchantCategoryMapping mapping =
|
||||||
|
merchantCategoryMappingService.resolveMapping(merchant, rawCategoryKey);
|
||||||
|
|
||||||
|
if (mapping != null && mapping.getMappedPartRole() != null && !mapping.getMappedPartRole().isBlank()) {
|
||||||
|
partRole = mapping.getMappedPartRole().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: keyword-based inference if we still don't have a mapped partRole
|
||||||
|
if (partRole == null || partRole.isBlank()) {
|
||||||
|
partRole = inferPartRole(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partRole == null || partRole.isBlank()) {
|
||||||
|
partRole = "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
p.setPartRole(partRole);
|
||||||
|
}
|
||||||
|
private void upsertOfferFromRow(Product product, Merchant merchant, MerchantFeedRow row) {
|
||||||
|
// For now, we’ll use SKU as the "avantlinkProductId" placeholder.
|
||||||
|
// If/when you have a real AvantLink product_id in the feed, switch to that.
|
||||||
|
String avantlinkProductId = trimOrNull(row.sku());
|
||||||
|
if (avantlinkProductId == null) {
|
||||||
|
// If there's truly no SKU, bail out – we can't match this offer reliably.
|
||||||
|
log.debug("Skipping offer row with no SKU for product id={}", product.getId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotent upsert: look for an existing offer for this merchant + AvantLink product id
|
||||||
|
ProductOffer offer = productOfferRepository
|
||||||
|
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
||||||
|
.orElseGet(ProductOffer::new);
|
||||||
|
|
||||||
|
// If this is a brand‑new offer, initialize key fields
|
||||||
|
if (offer.getId() == null) {
|
||||||
|
offer.setMerchant(merchant);
|
||||||
|
offer.setProduct(product);
|
||||||
|
offer.setAvantlinkProductId(avantlinkProductId);
|
||||||
|
offer.setFirstSeenAt(OffsetDateTime.now());
|
||||||
|
} else {
|
||||||
|
// Make sure associations stay in sync if anything changed
|
||||||
|
offer.setMerchant(merchant);
|
||||||
|
offer.setProduct(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifiers
|
||||||
|
offer.setSku(trimOrNull(row.sku()));
|
||||||
|
// No real UPC in this feed yet – leave null for now
|
||||||
|
offer.setUpc(null);
|
||||||
|
|
||||||
|
// Buy URL
|
||||||
|
offer.setBuyUrl(trimOrNull(row.buyLink()));
|
||||||
|
|
||||||
|
// Prices from feed
|
||||||
|
BigDecimal retail = row.retailPrice(); // parsed as BigDecimal in readFeedRowsForMerchant
|
||||||
|
BigDecimal sale = row.salePrice();
|
||||||
|
|
||||||
|
BigDecimal effectivePrice;
|
||||||
|
BigDecimal originalPrice;
|
||||||
|
|
||||||
|
// Prefer sale price if it exists and is less than or equal to retail
|
||||||
|
if (sale != null && (retail == null || sale.compareTo(retail) <= 0)) {
|
||||||
|
effectivePrice = sale;
|
||||||
|
originalPrice = (retail != null ? retail : sale);
|
||||||
|
} else {
|
||||||
|
// Otherwise fall back to retail or whatever is present
|
||||||
|
effectivePrice = (retail != null ? retail : sale);
|
||||||
|
originalPrice = (retail != null ? retail : sale);
|
||||||
|
}
|
||||||
|
|
||||||
|
offer.setPrice(effectivePrice);
|
||||||
|
offer.setOriginalPrice(originalPrice);
|
||||||
|
|
||||||
|
// Currency + stock
|
||||||
|
offer.setCurrency("USD");
|
||||||
|
// We don't have a real stock flag in this CSV, so assume in-stock for now
|
||||||
|
offer.setInStock(Boolean.TRUE);
|
||||||
|
|
||||||
|
// Update "last seen" on every import pass
|
||||||
|
offer.setLastSeenAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
productOfferRepository.save(offer);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Feed reading + brand resolution
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a Reader for either an HTTP(S) URL or a local file path.
|
||||||
|
*/
|
||||||
|
private Reader openFeedReader(String feedUrl) throws java.io.IOException {
|
||||||
|
if (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) {
|
||||||
|
return new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8);
|
||||||
|
} else {
|
||||||
|
return java.nio.file.Files.newBufferedReader(
|
||||||
|
java.nio.file.Paths.get(feedUrl),
|
||||||
|
StandardCharsets.UTF_8
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try a few common delimiters (tab, comma, semicolon) and pick the one
|
||||||
|
* that yields the expected AvantLink-style header set.
|
||||||
|
*/
|
||||||
|
private CSVFormat detectCsvFormat(String feedUrl) throws Exception {
|
||||||
|
char[] delimiters = new char[]{'\t', ',', ';'};
|
||||||
|
java.util.List<String> requiredHeaders =
|
||||||
|
java.util.Arrays.asList("SKU", "Manufacturer Id", "Brand Name", "Product Name");
|
||||||
|
|
||||||
|
Exception lastException = null;
|
||||||
|
|
||||||
|
for (char delimiter : delimiters) {
|
||||||
|
try (Reader reader = openFeedReader(feedUrl);
|
||||||
|
CSVParser parser = CSVFormat.DEFAULT.builder()
|
||||||
|
.setDelimiter(delimiter)
|
||||||
|
.setHeader()
|
||||||
|
.setSkipHeaderRecord(true)
|
||||||
|
.setIgnoreSurroundingSpaces(true)
|
||||||
|
.setTrim(true)
|
||||||
|
.build()
|
||||||
|
.parse(reader)) {
|
||||||
|
|
||||||
|
Map<String, Integer> headerMap = parser.getHeaderMap();
|
||||||
|
if (headerMap != null && headerMap.keySet().containsAll(requiredHeaders)) {
|
||||||
|
log.info("Detected delimiter '{}' for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), feedUrl);
|
||||||
|
|
||||||
|
return CSVFormat.DEFAULT.builder()
|
||||||
|
.setDelimiter(delimiter)
|
||||||
|
.setHeader()
|
||||||
|
.setSkipHeaderRecord(true)
|
||||||
|
.setIgnoreSurroundingSpaces(true)
|
||||||
|
.setTrim(true)
|
||||||
|
.build();
|
||||||
|
} else if (headerMap != null) {
|
||||||
|
log.debug("Delimiter '{}' produced headers {} for feed {}", (delimiter == '\t' ? "\\t" : String.valueOf(delimiter)), headerMap.keySet(), feedUrl);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
lastException = ex;
|
||||||
|
log.warn("Error probing delimiter '{}' for {}: {}", delimiter, feedUrl, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastException != null) {
|
||||||
|
throw lastException;
|
||||||
|
}
|
||||||
|
throw new RuntimeException("Could not auto-detect delimiter for feed: " + feedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<MerchantFeedRow> readFeedRowsForMerchant(Merchant merchant) {
|
||||||
|
String rawFeedUrl = merchant.getFeedUrl();
|
||||||
|
if (rawFeedUrl == null || rawFeedUrl.isBlank()) {
|
||||||
|
throw new IllegalStateException("Merchant " + merchant.getName() + " has no feed_url configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
String feedUrl = rawFeedUrl.trim();
|
||||||
|
log.info("Reading product feed for merchant={} from {}", merchant.getName(), feedUrl);
|
||||||
|
|
||||||
|
List<MerchantFeedRow> rows = new ArrayList<>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Auto-detect delimiter (TSV/CSV/semicolon) based on header row
|
||||||
|
CSVFormat format = detectCsvFormat(feedUrl);
|
||||||
|
|
||||||
|
try (Reader reader = openFeedReader(feedUrl);
|
||||||
|
CSVParser parser = new CSVParser(reader, format)) {
|
||||||
|
|
||||||
|
log.debug("Detected feed headers for merchant {}: {}", merchant.getName(), parser.getHeaderMap().keySet());
|
||||||
|
|
||||||
|
for (CSVRecord rec : parser) {
|
||||||
|
MerchantFeedRow row = new MerchantFeedRow(
|
||||||
|
getCsvValue(rec, "SKU"),
|
||||||
|
getCsvValue(rec, "Manufacturer Id"),
|
||||||
|
getCsvValue(rec, "Brand Name"),
|
||||||
|
getCsvValue(rec, "Product Name"),
|
||||||
|
getCsvValue(rec, "Long Description"),
|
||||||
|
getCsvValue(rec, "Short Description"),
|
||||||
|
getCsvValue(rec, "Department"),
|
||||||
|
getCsvValue(rec, "Category"),
|
||||||
|
getCsvValue(rec, "SubCategory"),
|
||||||
|
getCsvValue(rec, "Thumb URL"),
|
||||||
|
getCsvValue(rec, "Image URL"),
|
||||||
|
getCsvValue(rec, "Buy Link"),
|
||||||
|
getCsvValue(rec, "Keywords"),
|
||||||
|
getCsvValue(rec, "Reviews"),
|
||||||
|
parseBigDecimal(getCsvValue(rec, "Retail Price")),
|
||||||
|
parseBigDecimal(getCsvValue(rec, "Sale Price")),
|
||||||
|
getCsvValue(rec, "Brand Page Link"),
|
||||||
|
getCsvValue(rec, "Brand Logo Image"),
|
||||||
|
getCsvValue(rec, "Product Page View Tracking"),
|
||||||
|
null,
|
||||||
|
getCsvValue(rec, "Medium Image URL"),
|
||||||
|
getCsvValue(rec, "Product Content Widget"),
|
||||||
|
getCsvValue(rec, "Google Categorization"),
|
||||||
|
getCsvValue(rec, "Item Based Commission")
|
||||||
|
);
|
||||||
|
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
throw new RuntimeException("Failed to read feed for merchant "
|
||||||
|
+ merchant.getName() + " from " + feedUrl, ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Parsed {} product rows for merchant={}", rows.size(), merchant.getName());
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Brand resolveBrand(MerchantFeedRow row) {
|
||||||
|
String rawBrand = trimOrNull(row.brandName());
|
||||||
|
final String brandName = (rawBrand != null) ? rawBrand : "Aero Precision";
|
||||||
|
|
||||||
|
return brandRepository.findByNameIgnoreCase(brandName)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
Brand b = new Brand();
|
||||||
|
b.setName(brandName);
|
||||||
|
return brandRepository.save(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getCol(String[] cols, int index) {
|
||||||
|
return (index >= 0 && index < cols.length) ? cols[index] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BigDecimal parseBigDecimal(String raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
String trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
return new BigDecimal(trimmed);
|
||||||
|
} catch (NumberFormatException ex) {
|
||||||
|
log.debug("Skipping invalid numeric value '{}'", raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely get a column value by header name. If the record is "short"
|
||||||
|
* (fewer values than headers) or the header is missing, return null
|
||||||
|
* instead of throwing IllegalArgumentException.
|
||||||
|
*/
|
||||||
|
private String getCsvValue(CSVRecord rec, String header) {
|
||||||
|
if (rec == null || header == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!rec.isMapped(header)) {
|
||||||
|
// Header not present at all
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return rec.get(header);
|
||||||
|
} catch (IllegalArgumentException ex) {
|
||||||
|
log.debug("Short CSV record #{} missing column '{}', treating as null", rec.getRecordNumber(), header);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Misc helpers
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private String trimOrNull(String value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String coalesce(String... values) {
|
||||||
|
if (values == null) return null;
|
||||||
|
for (String v : values) {
|
||||||
|
if (v != null && !v.isBlank()) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateUniqueSlug(String baseSlug) {
|
||||||
|
String candidate = baseSlug;
|
||||||
|
int suffix = 1;
|
||||||
|
while (productRepository.existsBySlug(candidate)) {
|
||||||
|
candidate = baseSlug + "-" + suffix;
|
||||||
|
suffix++;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildRawCategoryKey(MerchantFeedRow row) {
|
||||||
|
String dept = trimOrNull(row.department());
|
||||||
|
String cat = trimOrNull(row.category());
|
||||||
|
String sub = trimOrNull(row.subCategory());
|
||||||
|
|
||||||
|
java.util.List<String> parts = new java.util.ArrayList<>();
|
||||||
|
if (dept != null) parts.add(dept);
|
||||||
|
if (cat != null) parts.add(cat);
|
||||||
|
if (sub != null) parts.add(sub);
|
||||||
|
|
||||||
|
if (parts.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String.join(" > ", parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String inferPlatform(MerchantFeedRow row) {
|
||||||
|
String department = coalesce(trimOrNull(row.department()), trimOrNull(row.category()));
|
||||||
|
if (department == null) return null;
|
||||||
|
|
||||||
|
String lower = department.toLowerCase();
|
||||||
|
if (lower.contains("ar-15") || lower.contains("ar15")) return "AR-15";
|
||||||
|
if (lower.contains("ar-10") || lower.contains("ar10")) return "AR-10";
|
||||||
|
if (lower.contains("ak-47") || lower.contains("ak47")) return "AK-47";
|
||||||
|
|
||||||
|
return "AR-15";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String inferPartRole(MerchantFeedRow row) {
|
||||||
|
String cat = coalesce(
|
||||||
|
trimOrNull(row.subCategory()),
|
||||||
|
trimOrNull(row.category())
|
||||||
|
);
|
||||||
|
if (cat == null) return null;
|
||||||
|
|
||||||
|
String lower = cat.toLowerCase();
|
||||||
|
|
||||||
|
if (lower.contains("handguard") || lower.contains("rail")) {
|
||||||
|
return "handguard";
|
||||||
|
}
|
||||||
|
if (lower.contains("barrel")) {
|
||||||
|
return "barrel";
|
||||||
|
}
|
||||||
|
if (lower.contains("upper")) {
|
||||||
|
return "upper-receiver";
|
||||||
|
}
|
||||||
|
if (lower.contains("lower")) {
|
||||||
|
return "lower-receiver";
|
||||||
|
}
|
||||||
|
if (lower.contains("magazine") || lower.contains("mag")) {
|
||||||
|
return "magazine";
|
||||||
|
}
|
||||||
|
if (lower.contains("stock") || lower.contains("buttstock")) {
|
||||||
|
return "stock";
|
||||||
|
}
|
||||||
|
if (lower.contains("grip")) {
|
||||||
|
return "grip";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
@CacheEvict(value = "gunbuilderProducts", allEntries = true)
|
||||||
|
public void syncOffersOnly(Integer merchantId) {
|
||||||
|
log.info("Starting offers-only sync for merchantId={}", merchantId);
|
||||||
|
Merchant merchant = merchantRepository.findById(merchantId)
|
||||||
|
.orElseThrow(() -> new RuntimeException("Merchant not found"));
|
||||||
|
|
||||||
|
if (Boolean.FALSE.equals(merchant.getIsActive())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String feedUrl = merchant.getOfferFeedUrl() != null
|
||||||
|
? merchant.getOfferFeedUrl()
|
||||||
|
: merchant.getFeedUrl();
|
||||||
|
|
||||||
|
if (feedUrl == null) {
|
||||||
|
throw new RuntimeException("No offer feed URL configured for merchant " + merchantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, String>> rows = fetchFeedRows(feedUrl);
|
||||||
|
|
||||||
|
for (Map<String, String> row : rows) {
|
||||||
|
upsertOfferOnlyFromRow(merchant, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
merchant.setLastOfferSyncAt(OffsetDateTime.now());
|
||||||
|
merchantRepository.save(merchant);
|
||||||
|
log.info("Completed offers-only sync for merchantId={} ({} rows processed)", merchantId, rows.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void upsertOfferOnlyFromRow(Merchant merchant, Map<String, String> row) {
|
||||||
|
// For the offer-only sync, we key offers by the same identifier we used when creating them.
|
||||||
|
// In the current AvantLink-style feed, that is the SKU column.
|
||||||
|
String avantlinkProductId = trimOrNull(row.get("SKU"));
|
||||||
|
if (avantlinkProductId == null || avantlinkProductId.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing offer
|
||||||
|
ProductOffer offer = productOfferRepository
|
||||||
|
.findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId)
|
||||||
|
.orElse(null);
|
||||||
|
|
||||||
|
if (offer == null) {
|
||||||
|
// This is a *sync* pass, not full ETL – if we don't already have an offer, skip.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse price fields (column names match the main product feed)
|
||||||
|
BigDecimal price = parseBigDecimal(row.get("Sale Price"));
|
||||||
|
BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price"));
|
||||||
|
|
||||||
|
// Update only *offer* fields – do not touch Product
|
||||||
|
offer.setPrice(price);
|
||||||
|
offer.setOriginalPrice(originalPrice);
|
||||||
|
offer.setInStock(parseInStock(row));
|
||||||
|
|
||||||
|
// Prefer a fresh Buy Link from the feed if present, otherwise keep existing
|
||||||
|
String newBuyUrl = trimOrNull(row.get("Buy Link"));
|
||||||
|
offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl()));
|
||||||
|
|
||||||
|
offer.setLastSeenAt(OffsetDateTime.now());
|
||||||
|
|
||||||
|
productOfferRepository.save(offer);
|
||||||
|
}
|
||||||
|
private Boolean parseInStock(Map<String, String> row) {
|
||||||
|
String inStock = trimOrNull(row.get("In Stock"));
|
||||||
|
if (inStock == null) return Boolean.FALSE;
|
||||||
|
|
||||||
|
String lower = inStock.toLowerCase();
|
||||||
|
if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) {
|
||||||
|
return Boolean.TRUE;
|
||||||
|
}
|
||||||
|
if (lower.contains("false") || lower.contains("no") || lower.contains("0")) {
|
||||||
|
return Boolean.FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean.FALSE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package group.goforward.ballistic.service;
|
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 org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -9,27 +10,31 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class PsaService {
|
public class PsaServiceImpl implements PsaService {
|
||||||
|
|
||||||
private final PsaRepository psaRepository;
|
private final PsaRepository psaRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PsaService(PsaRepository psaRepository) {
|
public PsaServiceImpl(PsaRepository psaRepository) {
|
||||||
this.psaRepository = psaRepository;
|
this.psaRepository = psaRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public List<Psa> findAll() {
|
public List<Psa> findAll() {
|
||||||
return psaRepository.findAll();
|
return psaRepository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Optional<Psa> findById(UUID id) {
|
public Optional<Psa> findById(UUID id) {
|
||||||
return psaRepository.findById(id);
|
return psaRepository.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public Psa save(Psa psa) {
|
public Psa save(Psa psa) {
|
||||||
return psaRepository.save(psa);
|
return psaRepository.save(psa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
public void deleteById(UUID id) {
|
public void deleteById(UUID id) {
|
||||||
psaRepository.deleteById(id);
|
psaRepository.deleteById(id);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
package group.goforward.ballistic.service.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.service.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;
|
||||||
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package group.goforward.ballistic.services.impl;
|
||||||
|
|
||||||
|
import group.goforward.ballistic.model.User;
|
||||||
|
import group.goforward.ballistic.repos.UserRepository;
|
||||||
|
import group.goforward.ballistic.services.UsersService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class UsersServiceImpl implements UsersService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository repo;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<User> findAll() {
|
||||||
|
return repo.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<User> findById(Integer id) {
|
||||||
|
return repo.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User save(User item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(Integer id) {
|
||||||
|
deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application.
|
||||||
|
* This package includes Services implementations for Spring-Boot application
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* <p>The main entry point for managing the inventory is the
|
||||||
|
* {@link group.goforward.ballistic.BallisticApplication} class.</p>
|
||||||
|
*
|
||||||
|
* @since 1.0
|
||||||
|
* @author Don Strawsburg
|
||||||
|
* @version 1.1
|
||||||
|
*/
|
||||||
|
package group.goforward.ballistic.services.impl;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
package group.goforward.ballistic.services;
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// MerchantAdminDto.java
|
||||||
|
package group.goforward.ballistic.web.dto;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public class MerchantAdminDto {
|
||||||
|
private Integer id;
|
||||||
|
private String name;
|
||||||
|
private String feedUrl;
|
||||||
|
private String offerFeedUrl;
|
||||||
|
private Boolean isActive;
|
||||||
|
private OffsetDateTime lastFullImportAt;
|
||||||
|
private OffsetDateTime lastOfferSyncAt;
|
||||||
|
|
||||||
|
public Integer getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Integer id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFeedUrl() {
|
||||||
|
return feedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFeedUrl(String feedUrl) {
|
||||||
|
this.feedUrl = feedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOfferFeedUrl() {
|
||||||
|
return offerFeedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOfferFeedUrl(String offerFeedUrl) {
|
||||||
|
this.offerFeedUrl = offerFeedUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getIsActive() {
|
||||||
|
return isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsActive(Boolean isActive) {
|
||||||
|
this.isActive = isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getLastFullImportAt() {
|
||||||
|
return lastFullImportAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastFullImportAt(OffsetDateTime lastFullImportAt) {
|
||||||
|
this.lastFullImportAt = lastFullImportAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getLastOfferSyncAt() {
|
||||||
|
return lastOfferSyncAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) {
|
||||||
|
this.lastOfferSyncAt = lastOfferSyncAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package group.goforward.ballistic.web.dto;
|
||||||
|
|
||||||
|
public class MerchantCategoryMappingDto {
|
||||||
|
|
||||||
|
private Integer id;
|
||||||
|
private Integer merchantId;
|
||||||
|
private String merchantName;
|
||||||
|
private String rawCategory;
|
||||||
|
private String mappedPartRole;
|
||||||
|
|
||||||
|
public Integer getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Integer id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getMerchantId() {
|
||||||
|
return merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMerchantId(Integer merchantId) {
|
||||||
|
this.merchantId = merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMerchantName() {
|
||||||
|
return merchantName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMerchantName(String merchantName) {
|
||||||
|
this.merchantName = merchantName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRawCategory() {
|
||||||
|
return rawCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRawCategory(String rawCategory) {
|
||||||
|
this.rawCategory = rawCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMappedPartRole() {
|
||||||
|
return mappedPartRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMappedPartRole(String mappedPartRole) {
|
||||||
|
this.mappedPartRole = mappedPartRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package group.goforward.ballistic.web.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public class ProductOfferDto {
|
||||||
|
private String id;
|
||||||
|
private String merchantName;
|
||||||
|
private BigDecimal price;
|
||||||
|
private BigDecimal originalPrice;
|
||||||
|
private boolean inStock;
|
||||||
|
private String buyUrl;
|
||||||
|
private OffsetDateTime lastUpdated;
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMerchantName() {
|
||||||
|
return merchantName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMerchantName(String merchantName) {
|
||||||
|
this.merchantName = merchantName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getPrice() {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrice(BigDecimal price) {
|
||||||
|
this.price = price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getOriginalPrice() {
|
||||||
|
return originalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOriginalPrice(BigDecimal originalPrice) {
|
||||||
|
this.originalPrice = originalPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isInStock() {
|
||||||
|
return inStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setInStock(boolean inStock) {
|
||||||
|
this.inStock = inStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBuyUrl() {
|
||||||
|
return buyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuyUrl(String buyUrl) {
|
||||||
|
this.buyUrl = buyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getLastUpdated() {
|
||||||
|
return lastUpdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setLastUpdated(OffsetDateTime lastUpdated) {
|
||||||
|
this.lastUpdated = lastUpdated;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package group.goforward.ballistic.web.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
public class ProductSummaryDto {
|
||||||
|
|
||||||
|
private String id; // product UUID as string
|
||||||
|
private String name;
|
||||||
|
private String brand;
|
||||||
|
private String platform;
|
||||||
|
private String partRole;
|
||||||
|
private String categoryKey;
|
||||||
|
private BigDecimal price;
|
||||||
|
private String buyUrl;
|
||||||
|
|
||||||
|
public String getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(String id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBrand() {
|
||||||
|
return brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBrand(String brand) {
|
||||||
|
this.brand = brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 String getCategoryKey() {
|
||||||
|
return categoryKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryKey(String categoryKey) {
|
||||||
|
this.categoryKey = categoryKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BigDecimal getPrice() {
|
||||||
|
return price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPrice(BigDecimal price) {
|
||||||
|
this.price = price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getBuyUrl() {
|
||||||
|
return buyUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setBuyUrl(String buyUrl) {
|
||||||
|
this.buyUrl = buyUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package group.goforward.ballistic.web.dto;
|
||||||
|
|
||||||
|
public class UpsertMerchantCategoryMappingRequest {
|
||||||
|
|
||||||
|
private Integer merchantId;
|
||||||
|
private String rawCategory;
|
||||||
|
private String mappedPartRole; // can be null to "unmap"
|
||||||
|
|
||||||
|
public Integer getMerchantId() {
|
||||||
|
return merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMerchantId(Integer merchantId) {
|
||||||
|
this.merchantId = merchantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRawCategory() {
|
||||||
|
return rawCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRawCategory(String rawCategory) {
|
||||||
|
this.rawCategory = rawCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMappedPartRole() {
|
||||||
|
return mappedPartRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMappedPartRole(String mappedPartRole) {
|
||||||
|
this.mappedPartRole = mappedPartRole;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package group.goforward.ballistic.web.dto.admin;
|
||||||
|
|
||||||
|
public record CreatePartRoleMappingRequest(
|
||||||
|
String platform,
|
||||||
|
String partRole,
|
||||||
|
String categorySlug,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -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
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package group.goforward.ballistic.web.dto.admin;
|
||||||
|
|
||||||
|
public record PartRoleMappingRequest(
|
||||||
|
String platform,
|
||||||
|
String partRole,
|
||||||
|
String categorySlug,
|
||||||
|
String notes
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package group.goforward.ballistic.web.dto.admin;
|
||||||
|
|
||||||
|
public record SimpleMerchantDto(
|
||||||
|
Integer id,
|
||||||
|
String name
|
||||||
|
) { }
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package group.goforward.ballistic.web.dto.admin;
|
||||||
|
|
||||||
|
public record UpdateMerchantCategoryMappingRequest(
|
||||||
|
Integer partCategoryId
|
||||||
|
) {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user