mirror of
https://gitea.gofwd.group/Forward_Group/ballistic-builder-spring.git
synced 2026-01-21 01:01:05 -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,4 +1,4 @@
|
|||||||
package group.goforward.battlbuilder.controllers;
|
package group.goforward.battlbuilder.controllers.admin;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.model.Platform;
|
import group.goforward.battlbuilder.model.Platform;
|
||||||
import group.goforward.battlbuilder.repos.PlatformRepository;
|
import group.goforward.battlbuilder.repos.PlatformRepository;
|
||||||
@@ -36,21 +36,70 @@ public class AdminPlatformController {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/add")
|
/**
|
||||||
public ResponseEntity<Platform> createPlatform(@RequestBody Platform platform) {
|
* Create new platform (RESTful)
|
||||||
platform.setCreatedAt(OffsetDateTime.now());
|
* POST /api/platforms
|
||||||
platform.setUpdatedAt(OffsetDateTime.now());
|
*/
|
||||||
Platform created = platformRepository.save(platform);
|
@PostMapping
|
||||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
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());
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/delete/{id}")
|
// Optional: if label empty, default to key
|
||||||
public ResponseEntity<Void> deletePlatform(@PathVariable Integer id) {
|
if (platform.getLabel() == null || platform.getLabel().trim().isEmpty()) {
|
||||||
return platformRepository.findById(id)
|
platform.setLabel(platform.getKey());
|
||||||
.map(platform -> {
|
}
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
new PlatformDto(
|
||||||
|
created.getId(),
|
||||||
|
created.getKey(),
|
||||||
|
created.getLabel(),
|
||||||
|
created.getCreatedAt(),
|
||||||
|
created.getUpdatedAt(),
|
||||||
|
created.getIsActive()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (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);
|
platformRepository.deleteById(id);
|
||||||
return ResponseEntity.noContent().<Void>build();
|
return ResponseEntity.noContent().build();
|
||||||
})
|
}
|
||||||
.orElse(ResponseEntity.notFound().build());
|
|
||||||
|
/**
|
||||||
|
* (Optional) keep old delete route temporarily
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/delete/{id}")
|
||||||
|
public ResponseEntity<Void> deleteLegacy(@PathVariable Integer id) {
|
||||||
|
return delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,13 @@ package group.goforward.battlbuilder.controllers;
|
|||||||
import group.goforward.battlbuilder.services.ProductQueryService;
|
import group.goforward.battlbuilder.services.ProductQueryService;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
import group.goforward.battlbuilder.web.dto.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
||||||
import org.springframework.cache.annotation.Cacheable;
|
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.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.web.PageableDefault;
|
import org.springframework.data.web.PageableDefault;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -23,17 +24,26 @@ public class ProductV1Controller {
|
|||||||
this.productQueryService = productQueryService;
|
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
|
@GetMapping
|
||||||
@Cacheable(
|
@Cacheable(
|
||||||
value = "gunbuilderProductsV1",
|
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(
|
public Page<ProductSummaryDto> getProducts(
|
||||||
@RequestParam(defaultValue = "AR-15") String platform,
|
@RequestParam(defaultValue = "AR-15") String platform,
|
||||||
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
|
@RequestParam(required = false, name = "partRoles") List<String> partRoles,
|
||||||
|
@RequestParam(name = "priceSort", defaultValue = "price_asc") String priceSort,
|
||||||
@PageableDefault(size = 50) Pageable pageable
|
@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")
|
@GetMapping("/{id}/offers")
|
||||||
@@ -3,6 +3,7 @@ package group.goforward.battlbuilder.repos;
|
|||||||
import group.goforward.battlbuilder.model.ImportStatus;
|
import group.goforward.battlbuilder.model.ImportStatus;
|
||||||
import group.goforward.battlbuilder.model.Brand;
|
import group.goforward.battlbuilder.model.Brand;
|
||||||
import group.goforward.battlbuilder.model.Product;
|
import group.goforward.battlbuilder.model.Product;
|
||||||
|
import group.goforward.battlbuilder.catalog.query.ProductWithBestPrice;
|
||||||
|
|
||||||
import group.goforward.battlbuilder.repos.projections.CatalogRow;
|
import group.goforward.battlbuilder.repos.projections.CatalogRow;
|
||||||
import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow;
|
import group.goforward.battlbuilder.web.dto.catalog.CatalogOptionRow;
|
||||||
@@ -625,6 +626,28 @@ ORDER BY productCount DESC
|
|||||||
Pageable pageable
|
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.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
|
import group.goforward.battlbuilder.web.dto.catalog.ProductSort;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface ProductQueryService {
|
public interface ProductQueryService {
|
||||||
|
|
||||||
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
List<ProductSummaryDto> getProducts(String platform, List<String> partRoles);
|
||||||
@@ -16,5 +16,10 @@ public interface ProductQueryService {
|
|||||||
|
|
||||||
ProductSummaryDto getProductById(Integer productId);
|
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.AdminProductSearchRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
import group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
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.Page;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
public interface AdminProductService {
|
public interface AdminProductService {
|
||||||
|
Page<ProductAdminRowDto> search(AdminProductSearchRequest request, Pageable pageable);
|
||||||
|
|
||||||
Page<ProductAdminRowDto> search(
|
BulkUpdateResult bulkUpdate(ProductBulkUpdateRequest request);
|
||||||
AdminProductSearchRequest request,
|
|
||||||
Pageable pageable
|
|
||||||
);
|
|
||||||
|
|
||||||
int bulkUpdate(ProductBulkUpdateRequest request);
|
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import group.goforward.battlbuilder.repos.ProductRepository;
|
|||||||
import group.goforward.battlbuilder.services.admin.AdminProductService;
|
import group.goforward.battlbuilder.services.admin.AdminProductService;
|
||||||
import group.goforward.battlbuilder.specs.ProductSpecifications;
|
import group.goforward.battlbuilder.specs.ProductSpecifications;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
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.ProductAdminRowDto;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
||||||
|
|
||||||
@@ -38,28 +39,75 @@ public class AdminProductServiceImpl implements AdminProductService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int bulkUpdate(ProductBulkUpdateRequest request) {
|
public BulkUpdateResult bulkUpdate(ProductBulkUpdateRequest request) {
|
||||||
var products = productRepository.findAllById(request.getProductIds());
|
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) {
|
if (request.getVisibility() != null) {
|
||||||
p.setVisibility(request.getVisibility());
|
p.setVisibility(request.getVisibility());
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
if (request.getStatus() != null) {
|
if (request.getStatus() != null) {
|
||||||
p.setStatus(request.getStatus());
|
p.setStatus(request.getStatus());
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
if (request.getBuilderEligible() != null) {
|
if (request.getBuilderEligible() != null) {
|
||||||
p.setBuilderEligible(request.getBuilderEligible());
|
p.setBuilderEligible(request.getBuilderEligible());
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
if (request.getAdminLocked() != null) {
|
if (request.getAdminLocked() != null) {
|
||||||
p.setAdminLocked(request.getAdminLocked());
|
p.setAdminLocked(request.getAdminLocked());
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
if (request.getAdminNote() != null) {
|
if (request.getAdminNote() != null) {
|
||||||
p.setAdminNote(request.getAdminNote());
|
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);
|
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.ProductOfferDto;
|
||||||
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
import group.goforward.battlbuilder.web.dto.ProductSummaryDto;
|
||||||
import group.goforward.battlbuilder.web.mapper.ProductMapper;
|
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.math.BigDecimal;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
@@ -16,6 +16,8 @@ import java.util.stream.Collectors;
|
|||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageImpl;
|
import org.springframework.data.domain.PageImpl;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
@@ -79,24 +81,37 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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");
|
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;
|
Page<Product> productPage;
|
||||||
|
|
||||||
if (partRoles == null || partRoles.isEmpty()) {
|
if (partRoles == null || partRoles.isEmpty()) {
|
||||||
productPage = allPlatforms
|
productPage = allPlatforms
|
||||||
? productRepository.findAllWithBrand(pageable)
|
? productRepository.findAllWithBrand(safePageable)
|
||||||
: productRepository.findByPlatformWithBrand(platform, pageable);
|
: productRepository.findByPlatformWithBrand(platform, safePageable);
|
||||||
} else {
|
} else {
|
||||||
productPage = allPlatforms
|
productPage = allPlatforms
|
||||||
? productRepository.findByPartRoleInWithBrand(partRoles, pageable)
|
? productRepository.findByPartRoleInWithBrand(partRoles, safePageable)
|
||||||
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, pageable);
|
: productRepository.findByPlatformAndPartRoleInWithBrand(platform, partRoles, safePageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Product> products = productPage.getContent();
|
List<Product> products = productPage.getContent();
|
||||||
if (products.isEmpty()) {
|
if (products.isEmpty()) {
|
||||||
return Page.empty(pageable);
|
return Page.empty(safePageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Integer> productIds = products.stream().map(Product::getId).toList();
|
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)
|
.filter(o -> o.getProduct() != null && o.getProduct().getId() != null)
|
||||||
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
.collect(Collectors.groupingBy(o -> o.getProduct().getId()));
|
||||||
|
|
||||||
|
// Build DTOs (same as before)
|
||||||
List<ProductSummaryDto> dtos = products.stream()
|
List<ProductSummaryDto> dtos = products.stream()
|
||||||
.map(p -> {
|
.map(p -> {
|
||||||
List<ProductOffer> offersForProduct =
|
List<ProductOffer> offersForProduct =
|
||||||
@@ -122,7 +138,17 @@ public class ProductQueryServiceImpl implements ProductQueryService {
|
|||||||
})
|
})
|
||||||
.toList();
|
.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.services.admin.AdminProductService;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
import group.goforward.battlbuilder.web.dto.admin.AdminProductSearchRequest;
|
||||||
import group.goforward.battlbuilder.web.dto.admin.ProductBulkUpdateRequest;
|
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 group.goforward.battlbuilder.web.dto.admin.ProductAdminRowDto;
|
||||||
|
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
@@ -36,10 +37,11 @@ public class AdminProductController {
|
|||||||
* Bulk admin actions (disable, hide, lock, etc.)
|
* Bulk admin actions (disable, hide, lock, etc.)
|
||||||
*/
|
*/
|
||||||
@PatchMapping("/bulk")
|
@PatchMapping("/bulk")
|
||||||
public Map<String, Object> bulkUpdate(
|
public Map<String, Object> bulkUpdate(@RequestBody ProductBulkUpdateRequest request) {
|
||||||
@RequestBody ProductBulkUpdateRequest request
|
BulkUpdateResult result = adminProductService.bulkUpdate(request);
|
||||||
) {
|
return Map.of(
|
||||||
int updated = adminProductService.bulkUpdate(request);
|
"updatedCount", result.updatedCount(),
|
||||||
return Map.of("updatedCount", updated);
|
"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 {
|
public class ProductBulkUpdateRequest {
|
||||||
|
|
||||||
private Set<Integer> productIds;
|
private Set<Integer> productIds;
|
||||||
|
private String platform;
|
||||||
|
private Boolean platformLocked;
|
||||||
private ProductVisibility visibility;
|
private ProductVisibility visibility;
|
||||||
private ProductStatus status;
|
private ProductStatus status;
|
||||||
|
|
||||||
@@ -36,4 +37,10 @@ public class ProductBulkUpdateRequest {
|
|||||||
|
|
||||||
public String getAdminNote() { return adminNote; }
|
public String getAdminNote() { return adminNote; }
|
||||||
public void setAdminNote(String adminNote) { this.adminNote = 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