Merge branch 'develop' of ssh://gitea.gofwd.group:2225/Forward_Group/ballistic-builder-spring into develop

This commit is contained in:
2026-01-05 22:06:49 -05:00
48 changed files with 1340 additions and 561 deletions

357
CLAUDE.md Normal file
View File

@@ -0,0 +1,357 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Ballistic Builder is a **PCPartPicker-style platform for firearms**, starting with the AR-15 ecosystem. It's a Spring Boot 3.4.3 application that:
- Ingests merchant feeds (AvantLink) and normalizes product data
- Manages complex category mappings and part role classification
- Provides REST APIs for a consumer-facing Builder application
- Enables users to browse parts, compare prices, and assemble builds
**Critical: This is NOT**:
- An e-commerce platform (does not sell products)
- A marketplace (does not process payments)
- A forum-first community
- An inventory management system
**It IS**:
- An affiliate-driven aggregation platform
- A data normalization engine disguised as a UI
- Focused on accuracy, clarity, and trust
Think: *"Build smarter rifles, not spreadsheets."*
## Core Principles
When working on this codebase, always prioritize:
- **Accuracy > Completeness** - Correct data is more important than comprehensive data
- **Idempotency > Speed** - Operations must be safely repeatable
- **Explicit data > Heuristics** - Prefer manual mappings over inference when accuracy matters
- **Long-term maintainability > Cleverness** - Code readability 6-12 months out matters more than clever abstractions
- **Incremental delivery** - Small team, phased solutions, clear migration paths
Avoid introducing:
- Real-time inventory guarantees (merchant feeds are authoritative)
- Payment processing or checkout flows
- Legal/compliance risk (this is an aggregator, not a retailer)
- Over-engineered abstractions or premature optimization
- "Rewrite everything" solutions
## Common Commands
### Build & Run
```bash
# Clean build
mvn clean install
# Run development server (port 8080)
mvn spring-boot:run
# Run with Spring Boot DevTools hot reload
mvn spring-boot:run -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005"
```
### Testing
```bash
# Run all tests
mvn test
# Run specific test class
mvn test -Dtest=PlatformResolverTest
# Run with coverage
mvn clean test jacoco:report
```
### Database
```bash
# PostgreSQL connection details in application.properties
# Default: jdbc:postgresql://r710.gofwd.group:5433/ss_builder
# User: postgres
```
### API Documentation
- Swagger UI: http://localhost:8080/swagger-ui.html
- OpenAPI spec: http://localhost:8080/v3/api-docs
## Architecture Overview
### Layered Architecture Pattern
```
Controllers (REST API endpoints)
Services (Business logic + validation)
Repositories (Spring Data JPA)
Models (JPA Entities)
PostgreSQL Database
```
### Package Structure
**Core Packages:**
- `catalog/classification/` - Platform resolution and product classification engine
- `controllers/` - REST API endpoints (v1 convention, admin panel)
- `enrichment/ai/` - AI-powered product data enhancement (OpenAI integration)
- `imports/` - CSV/TSV feed import functionality for merchant data
- `model/` - JPA entities (Product, User, Build, Brand, Merchant, etc.)
- `repos/` - Spring Data JPA repositories
- `security/` - JWT authentication, token validation, user details
- `services/` - Business logic layer (interface + impl pattern)
- `web/dto/` - Data Transfer Objects for API contracts
- `web/mapper/` - Entity to DTO conversion utilities
### Key Technologies
- Java 21
- Spring Boot 3.4.3 (Spring Data JPA, Spring Security, Spring Mail)
- PostgreSQL 42.7.7 with HikariCP connection pooling
- JWT authentication (JJWT 0.11.5)
- MinIO 8.4.3 (S3-compatible object storage for images)
- Apache Commons CSV 1.11.0 (feed processing)
- SpringDoc OpenAPI 2.8.5 (API documentation)
## Critical Domain Concepts
### Product Classification System
**Platform Resolution**: Products are automatically classified to gun platforms (AR-15, AK-47, etc.) using rule-based logic in `catalog/classification/PlatformResolver`. Rules are stored in the `platform_rule` table and evaluated against product attributes (name, brand, MPN, UPC).
**Part Role Mapping**: Products are assigned part roles (Upper Receiver, Barrel, etc.) through:
1. Automatic classification via `PartRoleRule` entities
2. Manual merchant category mappings in `MerchantCategoryMap`
3. Manual overrides with `platform_locked` flag
**Category Normalization**: Raw merchant categories are mapped to `CanonicalCategory` through `CategoryMapping` entities. Unmapped categories are exposed in the admin UI for manual assignment.
### Merchant Feed Ingestion (Critical)
The import system is the **heart of the platform**. The `imports/` package handles CSV/TSV feed imports from AvantLink merchants with these critical characteristics:
**Idempotency by Design**:
- Safe to re-run repeatedly without side effects
- Never duplicates products or offers
- Handles dirty, malformed, or incomplete feeds gracefully
**Import Modes**:
1. **Full Import** - Products + offers (initial or full sync)
2. **Offer-Only Sync** - Price/stock refresh for existing products
**Deduplication Rules**:
- Products deduped by **Brand + MPN** (primary key combination)
- UPC fallback for future enhancement
- Offers are **merchant-specific** (same product can have multiple offers)
- Offers track `firstSeenAt` and `lastSeenAt` timestamps
**Process Flow**:
- Auto-detects delimiters (CSV/TSV)
- Creates or updates `Product` entities
- Upserts `ProductOffer` entities (price, stock, merchant URL)
- Tracks `FeedImport` status and history
- Logs errors without breaking entire import
**Never Break Idempotency** - Any change to the import system must maintain the property that running imports multiple times produces the same result.
### Build System
Users create gun builds (`Build` entity) composed of `BuildItem` entities. Each build can be:
- Public (visible in community)
- Private (user-only)
- Linked to specific platforms
- Priced via aggregated `ProductOffer` data
### Authentication & Authorization
**JWT Flow**:
1. User logs in via `/api/auth/login`
2. `JwtService` generates access token (48-hour expiry)
3. Token stored in `AuthToken` table
4. `JwtAuthenticationFilter` validates token on each request
5. Principal stored as UUID string in SecurityContext
**Roles**: USER, ADMIN (role-based access control via Spring Security)
**Magic Links**: Users can request passwordless login links (30-day token expiry)
## Performance Considerations
### Recent Optimizations
- **API batch sizes reduced**: Catalog endpoints previously returned 2000 products per call, now paginated to ~48 items
- **DTO optimization**: `ProductDTO` streamlined for faster serialization
- **N+1 query prevention**: Use `@EntityGraph` annotations to eagerly fetch relations
### Caching Strategy
- Spring Caching enabled globally (`@EnableCaching`)
- Product details cached in `ProductV1Controller`
- Clear cache after imports or admin updates
### Database Query Patterns
- Use `JpaSpecificationExecutor` for dynamic filtering
- Complex aggregations use native PostgreSQL queries
- HikariCP max connection lifetime: 10 minutes
## API Design Patterns
### Endpoint Conventions
- Public API: `/api/v1/{resource}`
- Admin API: `/api/v1/admin/{resource}`
- Auth endpoints: `/api/auth/{action}`
- Legacy endpoints return 410 Gone (intentionally deprecated)
### Request/Response Flow
1. Controller receives request with DTOs
2. Validates input (Spring Validation)
3. Maps DTO to entity via mapper
4. Calls service layer (business logic)
5. Service interacts with repositories
6. Maps entity back to DTO
7. Returns JSON response
### Authorization Patterns
- JWT token in `Authorization: Bearer <token>` header
- Role checks via `@PreAuthorize("hasRole('ADMIN')")` or SecurityConfig rules
- User identity extracted from SecurityContext via `JwtAuthenticationFilter`
## Common Development Patterns
### Adding a New API Endpoint
1. Create DTO classes in `web/dto/` (request + response)
2. Create/update service interface in `services/`
3. Implement service in `services/impl/`
4. Create controller in `controllers/` (follow naming: `{Resource}V1Controller`)
5. Inject service via constructor
6. Add authorization rules in `SecurityConfig` or `@PreAuthorize`
7. Document with SpringDoc annotations (`@Operation`, `@ApiResponse`)
### Entity Relationships
- Use `@EntityGraph` to prevent N+1 queries
- Soft delete pattern: `deletedAt` field (filter `WHERE deleted_at IS NULL`)
- Temporal tracking: `createdAt`, `updatedAt` via `@PrePersist`/`@PreUpdate`
- Avoid bidirectional relationships unless necessary
### Service Layer Pattern
- Define interface: `public interface BrandService { ... }`
- Implement: `@Service public class BrandServiceImpl implements BrandService`
- Use constructor injection for dependencies
- Handle business logic, validation, and transactions
## Configuration
### Environment-Specific Settings
Key properties in `src/main/resources/application.properties`:
```properties
# Database
spring.datasource.url=jdbc:postgresql://r710.gofwd.group:5433/ss_builder
# JWT
security.jwt.secret=ballistic-test-secret-key-...
security.jwt.access-token-minutes=2880 # 48 hours
# MinIO (image storage)
minio.endpoint=https://minioapi.dev.gofwd.group
minio.bucket=battlbuilders
minio.public-base-url=https://minio.dev.gofwd.group
# Email (SMTP)
spring.mail.host=mail.goforwardmail.com
spring.mail.username=info@battl.builders
# AI/OpenAI
ai.openai.apiKey=sk-proj-...
ai.openai.model=gpt-4.1-mini
ai.minConfidence=0.75
# Feature Flags
app.api.legacy.enabled=false
app.beta.captureOnly=true
app.email.outbound-enabled=true
```
### CORS Configuration
Allowed origins configured in `CorsConfig`:
- `http://localhost:3000` (React dev)
- `http://localhost:8080`, `8070`, `4200`, `4201`
## Important Notes
### Legacy Code
- `ProductController` intentionally returns 410 Gone (deprecated API v0)
- Old `Account` model coexists with newer `User` model
- JSP support included but not actively used
### Security Warnings
- Credentials in `application.properties` should use environment variables in production
- Rotate OpenAI API key regularly
- Use secrets manager for email passwords
- Ensure magic link tokens have short expiration
### Multi-Tenancy
- System supports multiple merchants via `Merchant` entity
- Category mappings are merchant-specific (`MerchantCategoryMap`)
- Platform rules engine allows per-merchant classification overrides
### Testing Strategy
- TestNG framework included
- Minimal test coverage currently
- Key test: `PlatformResolverTest` for classification logic
- Integration tests should cover feed import flows
## Development Constraints & Guidelines
### Team Context
This is a **small team** building for **longevity and credibility**. Code decisions should account for:
- Code readability 6-12 months from now
- Incremental delivery over big-bang rewrites
- Clear migration paths when refactoring
- Minimal external dependencies unless truly necessary
### When Proposing Changes
- **Feature requests**: Propose phased, minimal solutions
- **Refactors**: Explain tradeoffs and provide migration steps
- **Debugging**: Reason from architecture first (check layers: controller → service → repo → entity)
- **Design questions**: Follow the PCPartPicker mental model
- **Scaling concerns**: Assume success but move incrementally
### What NOT to Suggest
- Rewriting large portions of the codebase
- Adding frameworks for problems that don't exist yet
- Magical inference engines or hidden behavior
- Solutions that introduce legal/compliance risk
- Features that assume this is an e-commerce checkout platform
### Frontend Assumptions
The Next.js/React frontend depends on this backend for:
- Normalized APIs (backend owns business logic)
- Part role authoritative data
- Backend-driven compatibility rules
- No duplication of backend logic in frontend
## How to Work with This Codebase
### To Understand a Feature
1. Find the REST endpoint in `controllers/`
2. Trace the service call in `services/` (interface → impl)
3. Check repository queries in `repos/`
4. Review model relationships in `model/`
5. Check DTOs in `web/dto/` for request/response contracts
### To Debug an Issue
1. Check controller layer for request validation
2. Verify service layer business logic
3. Inspect repository queries (check for N+1)
4. Review entity relationships and fetch strategies
5. Check application.properties for configuration issues
6. Review logs for SQL queries (Hibernate logging)
### Red Flags to Watch For
- Breaking idempotency in import system
- Introducing N+1 query problems
- Adding credentials to application.properties
- Creating bidirectional JPA relationships without careful consideration
- Bypassing the service layer from controllers
- Duplicating business logic in DTOs or controllers

195
ai-context.md Normal file
View File

@@ -0,0 +1,195 @@
# 🤖 Battl Builder — AI Context & Guardrails
> **Purpose:**
> This document provides persistent context for AI assistants working on the **Battl Builder** project.
> It defines the product vision, architecture, constraints, and expectations so AI outputs remain aligned, safe, and useful.
---
## 1. Project Overview
**Battl Builder** is a PCPartPicker-style platform for firearms, starting with the **AR-15 ecosystem**.
Users can:
- Browse firearm parts by role (upper, lower, barrel, etc.)
- Compare prices across multiple merchants
- Assemble builds using a visual Builder UI
- Share builds publicly
Battl Builder:
- **Does not sell products**
- **Does not process payments**
- Is **affiliate-driven** (AvantLink)
- Focuses on **clarity, accuracy, and trust**
Think: *“Build smarter rifles, not spreadsheets.”*
---
## 2. Product Philosophy
### Core Principles
- **Accuracy > Completeness**
- **Idempotency > Speed**
- **Explicit data > heuristics**
- **Long-term maintainability > cleverness**
### Non-Goals
AI should NOT assume this project is:
- An e-commerce checkout platform
- A firearm marketplace
- A forum-first community
- A content or media brand (for now)
Avoid suggestions that introduce:
- Real-time inventory guarantees
- Payment processing
- Legal/compliance risk
- Over-engineered abstractions
---
## 3. Current State (Beta)
### What Exists
- User authentication
- Functional Builder UI
- Dedicated part pages
- Basic builds community
- 2 merchants (AvantLink)
- Production Spring Boot backend
### Whats Intentionally Light
- Compatibility logic (rule-based, evolving)
- Social features
- Price history & analytics
- Public build discovery
This is a **real beta**, not a mock project.
---
## 4. Backend Architecture
### Tech Stack
- **Java 17**
- **Spring Boot 3.x**
- **PostgreSQL**
- **Hibernate / JPA**
- **Maven**
- **REST APIs**
- **Docker (local + infra)**
### Core Responsibilities
- Merchant feed ingestion (CSV / TSV)
- Product normalization & deduplication
- Offer upserts (price, stock, URLs)
- Category → Part Role mapping
- Platform inference (AR-15 today)
- Supplying clean data to the Builder UI
---
## 5. Import Pipeline (Critical Context)
The import system is the **heart of the platform**.
### Key Characteristics
- Idempotent by design
- Safe to re-run repeatedly
- Handles dirty, malformed feeds
- Never duplicates products or offers
### Import Modes
- **Full Import:** products + offers
- **Offer-Only Sync:** price/stock refresh
### Deduplication Rules
- Products deduped by **Brand + MPN** (UPC fallback later)
- Offers are **merchant-specific**
- Offers track:
- `firstSeenAt`
- `lastSeenAt`
⚠️ **Never introduce logic that breaks idempotency.**
⚠️ **Never assume merchant data is clean or complete.**
---
## 6. Frontend Expectations
- Next.js / React
- Backend owns business logic
- Frontend consumes normalized APIs
- No hardcoded compatibility rules
- No duplication of backend logic
The Builder UI assumes:
- Part roles are authoritative
- Compatibility is backend-driven
- Data is already normalized
---
## 7. Infrastructure & Dev Environment
- PostgreSQL (Docker or local)
- Spring Boot runs on `:8080`
- CI builds on push / PR
- Local dev favors simplicity over cleverness
---
## 8. Constraints You Must Respect
AI suggestions must account for:
- Small team
- Incremental delivery
- Clear migration paths
- Code readability 612 months out
Avoid:
- “Rewrite everything” answers
- Premature abstractions
- Magical inference engines
- Hidden or implicit behavior
---
## 9. How AI Should Respond
When assisting on this project:
- **Feature requests** → propose phased, minimal solutions
- **Refactors** → explain tradeoffs and migration steps
- **Debugging** → reason from architecture first
- **Design** → follow PCPartPicker mental models
- **Scaling** → assume success, move incrementally
If something is unclear:
- Ask **one focused question**
- Otherwise state assumptions and proceed
---
## 10. Mental Model Summary
Battl Builder is:
- A **builder, not a store**
- A **data normalization engine disguised as a UI**
- Opinionated where it matters
- Flexible where it counts
- Built for **credibility and longevity**
AI assistance should help the project grow **cleanly, safely, and intelligently**.
---
### ✅ Recommended Usage
Paste this file into:
- Cursor / Copilot project context
- ChatGPT system prompt
- Claude project memory
- `/docs/ai-context.md`
-

View File

@@ -0,0 +1,15 @@
package group.goforward.battlbuilder.catalog.query;
import java.math.BigDecimal;
import java.util.UUID;
public interface ProductWithBestPrice {
Long getId();
UUID getUuid();
String getName();
String getSlug();
String getPlatform();
String getPartRole();
BigDecimal getBestPrice(); // derived
}

View File

@@ -0,0 +1,4 @@
/**
* Query projections for the catalog domain.
*/
package group.goforward.battlbuilder.catalog.query;

View File

@@ -0,0 +1,4 @@
/**
* Command line runners and CLI utilities.
*/
package group.goforward.battlbuilder.cli;

View File

@@ -1,74 +1,74 @@
package group.goforward.battlbuilder.utils;
import java.time.LocalDateTime;
/**
* @param <T>
*/
public class ApiResponse<T> {
private static final String API_SUCCESS = "success";
private static final String API_FAILURE = "failure";
private static final String API_ERROR = "error";
private String[] messages;
private T data;
private String status;
private LocalDateTime timestamp;
private ApiResponse(String status, String[] message, T data) {
this.status = status;
this.messages = message;
this.data = data;
this.timestamp = LocalDateTime.now();
}
public static <T> ApiResponse<T> error(String message, T data) {
String[] msg = {message}; // Include the message
return new ApiResponse<>(API_ERROR, msg, data);
}
public static <T> ApiResponse<T> success(T data, String emailSentSuccessfully) {
String[] msg = {};
return new ApiResponse<>(API_SUCCESS, msg, data);
}
public static <T> ApiResponse<T> error(String[] messages) {
return new ApiResponse<>(API_ERROR, messages, null);
}
public static <T> ApiResponse<T> error(String message) {
String[] msg = {};
return new ApiResponse<>(API_ERROR, msg, null);
}
public String[] getMessages() {
return messages;
}
public void setMessages(String[] messages) {
this.messages = messages;
}
public T getData() {return data;}
public void setData(T data) {
this.data = data;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
}
package group.goforward.battlbuilder.utils;
import java.time.LocalDateTime;
/**
* @param <T>
*/
public class ApiResponse<T> {
private static final String API_SUCCESS = "success";
private static final String API_FAILURE = "failure";
private static final String API_ERROR = "error";
private String[] messages;
private T data;
private String status;
private LocalDateTime timestamp;
private ApiResponse(String status, String[] message, T data) {
this.status = status;
this.messages = message;
this.data = data;
this.timestamp = LocalDateTime.now();
}
public static <T> ApiResponse<T> error(String message, T data) {
String[] msg = {message}; // Include the message
return new ApiResponse<>(API_ERROR, msg, data);
}
public static <T> ApiResponse<T> success(T data, String emailSentSuccessfully) {
String[] msg = {};
return new ApiResponse<>(API_SUCCESS, msg, data);
}
public static <T> ApiResponse<T> error(String[] messages) {
return new ApiResponse<>(API_ERROR, messages, null);
}
public static <T> ApiResponse<T> error(String message) {
String[] msg = {};
return new ApiResponse<>(API_ERROR, msg, null);
}
public String[] getMessages() {
return messages;
}
public void setMessages(String[] messages) {
this.messages = messages;
}
public T getData() {return data;}
public void setData(T data) {
this.data = data;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getTimestamp() {
return timestamp;
}
public void setTimestamp(LocalDateTime timestamp) {
this.timestamp = timestamp;
}
}

View File

@@ -1,20 +1,20 @@
package group.goforward.battlbuilder.utils;
import org.springframework.context.annotation.Bean;
public class Counter {
Integer count = 0;
public void addOne() {
count +=1;
}
public Integer getCount() {
return count;
}
private void setCount(Integer count) {
this.count = count;
}
}
package group.goforward.battlbuilder.utils;
import org.springframework.context.annotation.Bean;
public class Counter {
Integer count = 0;
public void addOne() {
count +=1;
}
public Integer getCount() {
return count;
}
private void setCount(Integer count) {
this.count = count;
}
}

View File

@@ -1,22 +1,22 @@
package group.goforward.battlbuilder.configuration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// Must match the @Cacheable value(s) used in controllers/services.
// ProductV1Controller uses: "gunbuilderProductsV1"
return new ConcurrentMapCacheManager(
"gunbuilderProductsV1",
"gunbuilderProducts" // keep if anything else still references it
);
}
package group.goforward.battlbuilder.configuration;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// Must match the @Cacheable value(s) used in controllers/services.
// ProductV1Controller uses: "gunbuilderProductsV1"
return new ConcurrentMapCacheManager(
"gunbuilderProductsV1",
"gunbuilderProducts" // keep if anything else still references it
);
}
}

View File

@@ -1,84 +1,84 @@
// src/main/java/com/example/config/CorsConfig.java
package group.goforward.battlbuilder.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// Allow credentials
config.setAllowCredentials(true);
// Allow Angular development server
config.setAllowedOrigins(Arrays.asList(
"http://localhost:4200",
"http://localhost:4201",
"http://localhost:8070",
"https://localhost:8070",
"http://localhost:8080",
"https://localhost:8080",
"http://localhost:3000",
"https://localhost:3000",
"https://localhost:3000/gunbuilder",
"http://localhost:3000/gunbuilder",
"https://localhost:3000/builder",
"http://localhost:3000/builder"
));
// Allow all headers
config.addAllowedHeader("*");
// Allow all HTTP methods
config.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
));
// Expose headers
config.setExposedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Total-Count"
));
// Max age for preflight cache (1 hour)
config.setMaxAge(3600L);
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
// Alternative using WebMvcConfigurer:
/*
package group.goforward.citysites.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:4200")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
// src/main/java/com/example/config/CorsConfig.java
package group.goforward.battlbuilder.configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
// Allow credentials
config.setAllowCredentials(true);
// Allow Angular development server
config.setAllowedOrigins(Arrays.asList(
"http://localhost:4200",
"http://localhost:4201",
"http://localhost:8070",
"https://localhost:8070",
"http://localhost:8080",
"https://localhost:8080",
"http://localhost:3000",
"https://localhost:3000",
"https://localhost:3000/gunbuilder",
"http://localhost:3000/gunbuilder",
"https://localhost:3000/builder",
"http://localhost:3000/builder"
));
// Allow all headers
config.addAllowedHeader("*");
// Allow all HTTP methods
config.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
));
// Expose headers
config.setExposedHeaders(Arrays.asList(
"Authorization",
"Content-Type",
"X-Total-Count"
));
// Max age for preflight cache (1 hour)
config.setMaxAge(3600L);
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
// Alternative using WebMvcConfigurer:
/*
package group.goforward.citysites.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:4200")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
*/

View File

@@ -1,10 +1,10 @@
package group.goforward.battlbuilder.configuration;
import org.springframework.context.annotation.Configuration;
//import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
public class JpaConfig {
// Enables @CreatedDate / @LastModifiedDate processing
package group.goforward.battlbuilder.configuration;
import org.springframework.context.annotation.Configuration;
//import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
public class JpaConfig {
// Enables @CreatedDate / @LastModifiedDate processing
}

View File

@@ -1,4 +1,4 @@
package group.goforward.battlbuilder.controllers;
package group.goforward.battlbuilder.controllers.admin;
import group.goforward.battlbuilder.model.Platform;
import group.goforward.battlbuilder.repos.PlatformRepository;
@@ -36,21 +36,70 @@ public class AdminPlatformController {
.toList();
}
@PostMapping("/add")
public ResponseEntity<Platform> createPlatform(@RequestBody Platform platform) {
/**
* Create new platform (RESTful)
* POST /api/platforms
*/
@PostMapping
public ResponseEntity<PlatformDto> create(@RequestBody Platform platform) {
// Normalize key so we dont end up with Ak47 / ak47 / AK-47 variants
if (platform.getKey() != null) {
platform.setKey(platform.getKey().trim().toUpperCase());
}
// Optional: if label empty, default to key
if (platform.getLabel() == null || platform.getLabel().trim().isEmpty()) {
platform.setLabel(platform.getKey());
}
// Default active = true if omitted
if (platform.getIsActive() == null) {
platform.setIsActive(true);
}
platform.setCreatedAt(OffsetDateTime.now());
platform.setUpdatedAt(OffsetDateTime.now());
Platform created = platformRepository.save(platform);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
return ResponseEntity.status(HttpStatus.CREATED).body(
new PlatformDto(
created.getId(),
created.getKey(),
created.getLabel(),
created.getCreatedAt(),
created.getUpdatedAt(),
created.getIsActive()
)
);
}
@DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deletePlatform(@PathVariable Integer id) {
return platformRepository.findById(id)
.map(platform -> {
platformRepository.deleteById(id);
return ResponseEntity.noContent().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
/**
* (Optional) keep old endpoint temporarily so nothing breaks
*/
@PostMapping("/add")
public ResponseEntity<PlatformDto> createViaAdd(@RequestBody Platform platform) {
return create(platform);
}
}
/**
* Delete platform (RESTful)
* DELETE /api/platforms/{id}
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Integer id) {
if (!platformRepository.existsById(id)) {
return ResponseEntity.notFound().build();
}
platformRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
/**
* (Optional) keep old delete route temporarily
*/
@DeleteMapping("/delete/{id}")
public ResponseEntity<Void> deleteLegacy(@PathVariable Integer id) {
return delete(id);
}
}

View File

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

View File

@@ -1,34 +1,34 @@
package group.goforward.battlbuilder.controllers.api;
import group.goforward.battlbuilder.model.Build;
import group.goforward.battlbuilder.repos.BuildRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/v1/api/builds")
public class BuildController {
@Autowired
private BuildRepository repo;
@Autowired
// private BuildsService service;
//@Cacheable(value="getAllStates")
@GetMapping("/all")
public ResponseEntity<List<Build>> getAll() {
List<Build> builds = repo.findAll();
return ResponseEntity.ok(builds);
}
@GetMapping("/{id}")
public ResponseEntity<Build> getAllBuildsById(@PathVariable Integer id) {
return repo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}
package group.goforward.battlbuilder.controllers.api;
import group.goforward.battlbuilder.model.Build;
import group.goforward.battlbuilder.repos.BuildRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/v1/api/builds")
public class BuildController {
@Autowired
private BuildRepository repo;
@Autowired
// private BuildsService service;
//@Cacheable(value="getAllStates")
@GetMapping("/all")
public ResponseEntity<List<Build>> getAll() {
List<Build> builds = repo.findAll();
return ResponseEntity.ok(builds);
}
@GetMapping("/{id}")
public ResponseEntity<Build> getAllBuildsById(@PathVariable Integer id) {
return repo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -1,40 +1,40 @@
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.services.MerchantFeedImportService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"})
@CrossOrigin(origins = "http://localhost:3000")
public class ImportController {
private final MerchantFeedImportService merchantFeedImportService;
public ImportController(MerchantFeedImportService merchantFeedImportService) {
this.merchantFeedImportService = merchantFeedImportService;
}
/**
* Full product + offer import for a merchant.
*
* 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();
}
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.services.MerchantFeedImportService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping({"/api/admin/imports", "/api/v1/admin/imports"})
@CrossOrigin(origins = "http://localhost:3000")
public class ImportController {
private final MerchantFeedImportService merchantFeedImportService;
public ImportController(MerchantFeedImportService merchantFeedImportService) {
this.merchantFeedImportService = merchantFeedImportService;
}
/**
* Full product + offer import for a merchant.
*
* 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();
}
}

View File

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

View File

@@ -1,56 +1,56 @@
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* LEGACY CONTROLLER (Deprecated)
*
* Do not add new features here.
* Canonical API lives in ProductV1Controller (/api/v1/products).
*
* This exists only to keep older clients working temporarily.
* Disable by default using:
* app.api.legacy.enabled=false
*
* NOTE:
* Even when disabled, Spring still compiles this class. So it must not reference
* missing services/methods.
*/
@Deprecated
@RestController
@RequestMapping({"/api/products", "/api/v1/products"})
@CrossOrigin
@ConditionalOnProperty(name = "app.api.legacy.enabled", havingValue = "true", matchIfMissing = false)
public class ProductController {
private static final String MSG =
"Legacy endpoint disabled. Use /api/v1/products instead.";
@GetMapping
public ResponseEntity<?> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles
) {
// Legacy disabled by design (Option B cleanup)
return ResponseEntity.status(410).body(MSG);
}
@GetMapping("/{id}/offers")
public ResponseEntity<?> getOffersForProduct(@PathVariable("id") Integer productId) {
return ResponseEntity.status(410).body(MSG);
}
@GetMapping("/{id}")
public ResponseEntity<?> getProductById(@PathVariable("id") Integer productId) {
return ResponseEntity.status(410).body(MSG);
}
// If you *really* need typed responses for an old client, we can re-add
// a real service layer once we align on the actual ProductQueryService API.
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* LEGACY CONTROLLER (Deprecated)
*
* Do not add new features here.
* Canonical API lives in ProductV1Controller (/api/v1/products).
*
* This exists only to keep older clients working temporarily.
* Disable by default using:
* app.api.legacy.enabled=false
*
* NOTE:
* Even when disabled, Spring still compiles this class. So it must not reference
* missing services/methods.
*/
@Deprecated
@RestController
@RequestMapping({"/api/products", "/api/v1/products"})
@CrossOrigin
@ConditionalOnProperty(name = "app.api.legacy.enabled", havingValue = "true", matchIfMissing = false)
public class ProductController {
private static final String MSG =
"Legacy endpoint disabled. Use /api/v1/products instead.";
@GetMapping
public ResponseEntity<?> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles
) {
// Legacy disabled by design (Option B cleanup)
return ResponseEntity.status(410).body(MSG);
}
@GetMapping("/{id}/offers")
public ResponseEntity<?> getOffersForProduct(@PathVariable("id") Integer productId) {
return ResponseEntity.status(410).body(MSG);
}
@GetMapping("/{id}")
public ResponseEntity<?> getProductById(@PathVariable("id") Integer productId) {
return ResponseEntity.status(410).body(MSG);
}
// If you *really* need typed responses for an old client, we can re-add
// a real service layer once we align on the actual ProductQueryService API.
}

View File

@@ -3,12 +3,13 @@ package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.services.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -23,17 +24,26 @@ public class ProductV1Controller {
this.productQueryService = productQueryService;
}
/**
* Product list endpoint
* Example:
* /api/v1/products?platform=AR-15&partRoles=upper-receiver&priceSort=price_asc&page=0&size=50
*
* NOTE: do NOT use `sort=` here Spring reserves it for Pageable sorting.
*/
@GetMapping
@Cacheable(
value = "gunbuilderProductsV1",
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles.toString()) + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
key = "#platform + '::' + (#partRoles == null ? 'ALL' : #partRoles) + '::' + #priceSort + '::' + #pageable.pageNumber + '::' + #pageable.pageSize"
)
public Page<ProductSummaryDto> getProducts(
@RequestParam(defaultValue = "AR-15") String platform,
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
@RequestParam(name = "priceSort", defaultValue = "price_asc") String priceSort,
@PageableDefault(size = 50) Pageable pageable
) {
return productQueryService.getProductsPage(platform, partRoles, pageable);
ProductSort sortEnum = ProductSort.from(priceSort);
return productQueryService.getProductsPage(platform, partRoles, pageable, sortEnum);
}
@GetMapping("/{id}/offers")

View File

@@ -1,55 +1,55 @@
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.model.State;
import group.goforward.battlbuilder.repos.StateRepository;
import group.goforward.battlbuilder.services.admin.StatesService;
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/states", "/api/v1/states"})
public class StateController {
@Autowired
private StateRepository repo;
@Autowired
private StatesService statesService;
//@Cacheable(value="getAllStates")
@GetMapping("/all")
public ResponseEntity<List<State>> getAllStates() {
List<State> state = repo.findAll();
return ResponseEntity.ok(state);
}
@GetMapping("/{id}")
public ResponseEntity<State> getAllStatesById(@PathVariable Integer id) {
return repo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/byAbbrev/{abbreviation}")
public ResponseEntity<State> getAllStatesByAbbreviation(@PathVariable String abbreviation) {
return repo.findByAbbreviation(abbreviation)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/addState")
public ResponseEntity<State> createState(@RequestBody State item) {
State created = statesService.save(item);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@DeleteMapping("/deleteState/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return statesService.findById(id)
.map(item -> {
statesService.deleteById(id);
return ResponseEntity.noContent().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
}
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.model.State;
import group.goforward.battlbuilder.repos.StateRepository;
import group.goforward.battlbuilder.services.admin.StatesService;
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/states", "/api/v1/states"})
public class StateController {
@Autowired
private StateRepository repo;
@Autowired
private StatesService statesService;
//@Cacheable(value="getAllStates")
@GetMapping("/all")
public ResponseEntity<List<State>> getAllStates() {
List<State> state = repo.findAll();
return ResponseEntity.ok(state);
}
@GetMapping("/{id}")
public ResponseEntity<State> getAllStatesById(@PathVariable Integer id) {
return repo.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@GetMapping("/byAbbrev/{abbreviation}")
public ResponseEntity<State> getAllStatesByAbbreviation(@PathVariable String abbreviation) {
return repo.findByAbbreviation(abbreviation)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
@PostMapping("/addState")
public ResponseEntity<State> createState(@RequestBody State item) {
State created = statesService.save(item);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@DeleteMapping("/deleteState/{id}")
public ResponseEntity<Void> deleteItem(@PathVariable Integer id) {
return statesService.findById(id)
.map(item -> {
statesService.deleteById(id);
return ResponseEntity.noContent().<Void>build();
})
.orElse(ResponseEntity.notFound().build());
}
}

View File

@@ -1,52 +1,52 @@
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repos.UserRepository;
import group.goforward.battlbuilder.services.admin.UsersService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping({"/api/user", "/api/v1/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());
}
}
package group.goforward.battlbuilder.controllers;
import group.goforward.battlbuilder.model.User;
import group.goforward.battlbuilder.repos.UserRepository;
import group.goforward.battlbuilder.services.admin.UsersService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping({"/api/user", "/api/v1/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());
}
}

View File

@@ -3,6 +3,7 @@ package group.goforward.battlbuilder.repos;
import group.goforward.battlbuilder.model.ImportStatus;
import group.goforward.battlbuilder.model.Brand;
import group.goforward.battlbuilder.model.Product;
import group.goforward.battlbuilder.catalog.query.ProductWithBestPrice;
import group.goforward.battlbuilder.repos.projections.CatalogRow;
import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow;
@@ -625,6 +626,28 @@ ORDER BY productCount DESC
Pageable pageable
);
// -------------------------------------------------
// Best Price
// -------------------------------------------------
@Query("""
select
p.id as id,
p.uuid as uuid,
p.name as name,
p.slug as slug,
p.platform as platform,
p.partRole as partRole,
min(o.price) as bestPrice
from Product p
join ProductOffer o on o.product.id = p.id
where (:platform is null or p.platform = :platform)
and o.inStock = true
group by p.id, p.uuid, p.name, p.slug, p.platform, p.partRole
""")
Page<ProductWithBestPrice> findProductsWithBestPriceInStock(
@Param("platform") String platform,
Pageable pageable
);
}

View File

@@ -2,12 +2,12 @@ package group.goforward.battlbuilder.services;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import java.util.List;
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.List;
public interface ProductQueryService {
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
@@ -16,5 +16,10 @@ public interface ProductQueryService {
ProductSummaryDto getProductById(Integer productId);
Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable);
Page<ProductSummaryDto> getProductsPage(
String platform,
List<String> partRoles,
Pageable pageable,
ProductSort sort
);
}

View File

@@ -3,16 +3,13 @@ package group.goforward.battlbuilder.services.admin;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import group.goforward.battlbuilder.web.dto.admin.BulkUpdateResult;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface AdminProductService {
Page<ProductAdminRowDto> search(AdminProductSearchRequest request, Pageable pageable);
Page<ProductAdminRowDto> search(
AdminProductSearchRequest request,
Pageable pageable
);
int bulkUpdate(ProductBulkUpdateRequest request);
BulkUpdateResult bulkUpdate(ProductBulkUpdateRequest request);
}

View File

@@ -5,6 +5,7 @@ import group.goforward.battlbuilder.repos.ProductRepository;
import group.goforward.battlbuilder.services.admin.AdminProductService;
import group.goforward.battlbuilder.specs.ProductSpecifications;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.BulkUpdateResult;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
@@ -38,28 +39,75 @@ public class AdminProductServiceImpl implements AdminProductService {
}
@Override
public int bulkUpdate(ProductBulkUpdateRequest request) {
public BulkUpdateResult bulkUpdate(ProductBulkUpdateRequest request) {
var products = productRepository.findAllById(request.getProductIds());
products.forEach(p -> {
int updated = 0;
int skippedLocked = 0;
for (var p : products) {
boolean changed = false;
// --- straightforward fields ---
if (request.getVisibility() != null) {
p.setVisibility(request.getVisibility());
changed = true;
}
if (request.getStatus() != null) {
p.setStatus(request.getStatus());
changed = true;
}
if (request.getBuilderEligible() != null) {
p.setBuilderEligible(request.getBuilderEligible());
changed = true;
}
if (request.getAdminLocked() != null) {
p.setAdminLocked(request.getAdminLocked());
changed = true;
}
if (request.getAdminNote() != null) {
p.setAdminNote(request.getAdminNote());
changed = true;
}
});
// --- platform update with lock semantics ---
if (request.getPlatform() != null) {
boolean isLocked = Boolean.TRUE.equals(p.getPlatformLocked());
boolean override = Boolean.TRUE.equals(request.getPlatformLocked()); // request says "I'm allowed to touch locked ones"
if (isLocked && !override) {
skippedLocked++;
} else {
if (!request.getPlatform().equals(p.getPlatform())) {
p.setPlatform(request.getPlatform());
changed = true;
}
}
}
// --- apply platformLocked toggle (even if platform isn't being changed) ---
if (request.getPlatformLocked() != null) {
if (!request.getPlatformLocked().equals(p.getPlatformLocked())) {
p.setPlatformLocked(request.getPlatformLocked());
changed = true;
}
}
if (changed) updated++;
}
productRepository.saveAll(products);
return products.size();
productRepository.flush(); // ✅ ensures UPDATEs are executed now
var check = productRepository.findAllById(request.getProductIds());
for (var p : check) {
System.out.println(
"AFTER FLUSH id=" + p.getId()
+ " platform=" + p.getPlatform()
+ " platformLocked=" + p.getPlatformLocked()
);
}
return new BulkUpdateResult(updated, skippedLocked);
}
}

View File

@@ -8,7 +8,7 @@ import group.goforward.battlbuilder.services.ProductQueryService;
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
import group.goforward.battlbuilder.web.mapper.ProductMapper;
import org.springframework.stereotype.Service;
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
import java.math.BigDecimal;
import java.util.*;
@@ -16,6 +16,8 @@ import java.util.stream.Collectors;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
import java.util.Collections;
@@ -79,24 +81,37 @@ public class ProductQueryServiceImpl implements ProductQueryService {
}
@Override
public Page<ProductSummaryDto> getProductsPage(String platform, List<String> partRoles, Pageable pageable) {
public Page<ProductSummaryDto> getProductsPage(
String platform,
List<String> partRoles,
Pageable pageable,
ProductSort sort
) {
final boolean allPlatforms = platform != null && platform.equalsIgnoreCase("ALL");
// IMPORTANT: ignore Pageable sorting (because we are doing our own "best price" logic)
// If the client accidentally passes ?sort=..., Spring Data will try ordering by a Product field.
// We'll strip it to be safe.
Pageable safePageable = pageable;
if (pageable != null && pageable.getSort() != null && pageable.getSort().isSorted()) {
safePageable = Pageable.ofSize(pageable.getPageSize()).withPage(pageable.getPageNumber());
}
Page<Product> productPage;
if (partRoles == null || partRoles.isEmpty()) {
productPage = allPlatforms
? productRepository.findAllWithBrand(pageable)
: productRepository.findByPlatformWithBrand(platform, pageable);
? productRepository.findAllWithBrand(safePageable)
: productRepository.findByPlatformWithBrand(platform, safePageable);
} else {
productPage = allPlatforms
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
? productRepository.findByPartRoleInWithBrand(partRoles, safePageable)
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, safePageable);
}
List<Product> products = productPage.getContent();
if (products.isEmpty()) {
return Page.empty(pageable);
return Page.empty(safePageable);
}
List<Integer> productIds = products.stream().map(Product::getId).toList();
@@ -108,6 +123,7 @@ public class ProductQueryServiceImpl implements ProductQueryService {
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
// Build DTOs (same as before)
List<ProductSummaryDto> dtos = products.stream()
.map(p -> {
List<ProductOffer> offersForProduct =
@@ -122,7 +138,17 @@ public class ProductQueryServiceImpl implements ProductQueryService {
})
.toList();
return new PageImpl<>(dtos, pageable, productPage.getTotalElements());
// Phase 3 "server-side sort by price" (within the page for now)
Comparator<ProductSummaryDto> byPriceNullLast =
Comparator.comparing(ProductSummaryDto::getPrice, Comparator.nullsLast(Comparator.naturalOrder()));
if (sort == ProductSort.PRICE_DESC) {
dtos = dtos.stream().sorted(byPriceNullLast.reversed()).toList();
} else {
dtos = dtos.stream().sorted(byPriceNullLast).toList();
}
return new PageImpl<>(dtos, safePageable, productPage.getTotalElements());
}
//

View File

@@ -3,6 +3,7 @@ package group.goforward.battlbuilder.web.admin;
import group.goforward.battlbuilder.services.admin.AdminProductService;
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
import group.goforward.battlbuilder.web.dto.admin.BulkUpdateResult;
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
import org.springframework.data.domain.Page;
@@ -36,10 +37,11 @@ public class AdminProductController {
* Bulk admin actions (disable, hide, lock, etc.)
*/
@PatchMapping("/bulk")
public Map<String, Object> bulkUpdate(
@RequestBody ProductBulkUpdateRequest request
) {
int updated = adminProductService.bulkUpdate(request);
return Map.of("updatedCount", updated);
public Map<String, Object> bulkUpdate(@RequestBody ProductBulkUpdateRequest request) {
BulkUpdateResult result = adminProductService.bulkUpdate(request);
return Map.of(
"updatedCount", result.updatedCount(),
"skippedLockedCount", result.skippedLockedCount()
);
}
}

View File

@@ -0,0 +1,6 @@
package group.goforward.battlbuilder.web.dto.admin;
public record BulkUpdateResult(
int updatedCount,
int skippedLockedCount
) {}

View File

@@ -0,0 +1,16 @@
package group.goforward.battlbuilder.web.dto.admin;
public class PlatformCreateRequest {
private String key;
private String label;
private Boolean isActive;
public String getKey() { return key; }
public void setKey(String key) { this.key = key; }
public String getLabel() { return label; }
public void setLabel(String label) { this.label = label; }
public Boolean getIsActive() { return isActive; }
public void setIsActive(Boolean isActive) { this.isActive = isActive; }
}

View File

@@ -8,7 +8,8 @@ import java.util.Set;
public class ProductBulkUpdateRequest {
private Set<Integer> productIds;
private String platform;
private Boolean platformLocked;
private ProductVisibility visibility;
private ProductStatus status;
@@ -36,4 +37,10 @@ public class ProductBulkUpdateRequest {
public String getAdminNote() { return adminNote; }
public void setAdminNote(String adminNote) { this.adminNote = adminNote; }
public String getPlatform() { return platform; }
public void setPlatform(String platform) { this.platform = platform; }
public Boolean getPlatformLocked() { return platformLocked; }
public void setPlatformLocked(Boolean platformLocked) { this.platformLocked = platformLocked; }
}

View File

@@ -0,0 +1,15 @@
package group.goforward.battlbuilder.web.dto.catalog;
public enum ProductSort {
PRICE_ASC,
PRICE_DESC;
public static ProductSort from(String raw) {
if (raw == null) return PRICE_ASC;
String s = raw.trim().toLowerCase();
return switch (s) {
case "price_desc", "price-desc", "desc" -> PRICE_DESC;
default -> PRICE_ASC;
};
}
}