mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-20 16:51:03 -05:00
Merge branch 'develop' of ssh://gitea.gofwd.group:2225/Forward_Group/ballistic-builder-spring into develop
This commit is contained in:
357
CLAUDE.md
Normal file
357
CLAUDE.md
Normal 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
195
ai-context.md
Normal 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
|
||||
|
||||
### What’s 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 6–12 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`
|
||||
-
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Query projections for the catalog domain.
|
||||
*/
|
||||
package group.goforward.battlbuilder.catalog.query;
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Command line runners and CLI utilities.
|
||||
*/
|
||||
package group.goforward.battlbuilder.cli;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 don’t 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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package group.goforward.battlbuilder.web.dto.admin;
|
||||
|
||||
public record BulkUpdateResult(
|
||||
int updatedCount,
|
||||
int skippedLockedCount
|
||||
) {}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user