list() {
+ return partCategories.findAllByOrderByGroupNameAscSortOrderAscNameAsc()
+ .stream()
+ .map(pc -> new PartCategoryDto(
+ pc.getId(),
+ pc.getSlug(),
+ pc.getName(),
+ pc.getDescription(),
+ pc.getGroupName(),
+ pc.getSortOrder()
+ ))
+ .toList();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/controllers/package-info.java b/src/main/java/group/goforward/ballistic/controllers/package-info.java
new file mode 100644
index 0000000..c49339c
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/controllers/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * Provides the classes necessary for the Spring Controllers for the ballistic -Builder application.
+ * This package includes Controllers for Spring-Boot application
+ *
+ *
+ * The main entry point for managing the inventory is the
+ * {@link group.goforward.ballistic.BallisticApplication} class.
+ *
+ * @since 1.0
+ * @author Don Strawsburg
+ * @version 1.1
+ */
+package group.goforward.ballistic.controllers;
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java
new file mode 100644
index 0000000..4ed5dab
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/imports/MerchantFeedRow.java
@@ -0,0 +1,30 @@
+package group.goforward.ballistic.imports;
+
+import java.math.BigDecimal;
+
+public record MerchantFeedRow(
+ String sku,
+ String manufacturerId,
+ String brandName,
+ String productName,
+ String longDescription,
+ String shortDescription,
+ String department,
+ String category,
+ String subCategory,
+ String thumbUrl,
+ String imageUrl,
+ String buyLink,
+ String keywords,
+ String reviews,
+ BigDecimal retailPrice,
+ BigDecimal salePrice,
+ String brandPageLink,
+ String brandLogoImage,
+ String productPageViewTracking,
+ String variantsXml,
+ String mediumImageUrl,
+ String productContentWidget,
+ String googleCategorization,
+ String itemBasedCommission
+) {}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java
new file mode 100644
index 0000000..dfa6eb1
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/imports/dto/MerchantFeedRow.java
@@ -0,0 +1,17 @@
+package group.goforward.ballistic.imports.dto;
+
+import java.math.BigDecimal;
+
+public record MerchantFeedRow(
+ String brandName,
+ String productName,
+ String mpn,
+ String upc,
+ String avantlinkProductId,
+ String sku,
+ String categoryPath,
+ String buyUrl,
+ BigDecimal price,
+ BigDecimal originalPrice,
+ boolean inStock
+) {}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/imports/dto/package-info.java b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java
new file mode 100644
index 0000000..586776b
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/imports/dto/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * Provides the classes necessary for the Spring Data Transfer Objects for the ballistic -Builder application.
+ * This package includes DTO for Spring-Boot application
+ *
+ *
+ * The main entry point for managing the inventory is the
+ * {@link group.goforward.ballistic.BallisticApplication} class.
+ *
+ * @since 1.0
+ * @author Sean Strawsburg
+ * @version 1.1
+ */
+package group.goforward.ballistic.imports.dto;
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java b/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java
index 3e623dc..eee49ec 100644
--- a/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java
+++ b/src/main/java/group/goforward/ballistic/model/AffiliateCategoryMap.java
@@ -5,23 +5,32 @@ import jakarta.persistence.*;
@Entity
@Table(name = "affiliate_category_map")
public class AffiliateCategoryMap {
+
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
- @Column(name = "id", nullable = false)
private Integer id;
- @Column(name = "feedname", nullable = false, length = 100)
- private String feedname;
+ // e.g. "PART_ROLE", "RAW_CATEGORY", etc.
+ @Column(name = "source_type", nullable = false)
+ private String sourceType;
- @Column(name = "affiliatecategory", nullable = false)
- private String affiliatecategory;
+ // the value we’re mapping from (e.g. "suppressor", "TRIGGER")
+ @Column(name = "source_value", nullable = false)
+ private String sourceValue;
- @Column(name = "buildercategoryid", nullable = false)
- private Integer buildercategoryid;
+ // optional platform ("AR-15", "PRECISION", etc.)
+ @Column(name = "platform")
+ private String platform;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "part_category_id", nullable = false)
+ private PartCategory partCategory;
@Column(name = "notes")
private String notes;
+ // --- getters / setters ---
+
public Integer getId() {
return id;
}
@@ -30,28 +39,36 @@ public class AffiliateCategoryMap {
this.id = id;
}
- public String getFeedname() {
- return feedname;
+ public String getSourceType() {
+ return sourceType;
}
- public void setFeedname(String feedname) {
- this.feedname = feedname;
+ public void setSourceType(String sourceType) {
+ this.sourceType = sourceType;
}
- public String getAffiliatecategory() {
- return affiliatecategory;
+ public String getSourceValue() {
+ return sourceValue;
}
- public void setAffiliatecategory(String affiliatecategory) {
- this.affiliatecategory = affiliatecategory;
+ public void setSourceValue(String sourceValue) {
+ this.sourceValue = sourceValue;
}
- public Integer getBuildercategoryid() {
- return buildercategoryid;
+ public String getPlatform() {
+ return platform;
}
- public void setBuildercategoryid(Integer buildercategoryid) {
- this.buildercategoryid = buildercategoryid;
+ public void setPlatform(String platform) {
+ this.platform = platform;
+ }
+
+ public PartCategory getPartCategory() {
+ return partCategory;
+ }
+
+ public void setPartCategory(PartCategory partCategory) {
+ this.partCategory = partCategory;
}
public String getNotes() {
@@ -61,5 +78,4 @@ public class AffiliateCategoryMap {
public void setNotes(String notes) {
this.notes = notes;
}
-
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/Brand.java b/src/main/java/group/goforward/ballistic/model/Brand.java
index 7a05dc9..f022d90 100644
--- a/src/main/java/group/goforward/ballistic/model/Brand.java
+++ b/src/main/java/group/goforward/ballistic/model/Brand.java
@@ -1,10 +1,8 @@
package group.goforward.ballistic.model;
-import jakarta.persistence.Column;
-import jakarta.persistence.Entity;
-import jakarta.persistence.Id;
-import jakarta.persistence.Table;
-import org.hibernate.annotations.ColumnDefault;
+import jakarta.persistence.*;
+import org.hibernate.annotations.CreationTimestamp;
+import org.hibernate.annotations.UpdateTimestamp;
import java.time.Instant;
import java.util.UUID;
@@ -12,39 +10,48 @@ import java.util.UUID;
@Entity
@Table(name = "brands")
public class Brand {
+
@Id
- @Column(name = "id", nullable = false)
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
- @Column(name = "name", nullable = false, length = 100)
+ @Column(nullable = false, unique = true)
private String name;
- @ColumnDefault("now()")
+ @Column(nullable = false, unique = true)
+ private UUID uuid;
+
+ @Column(unique = true)
+ private String slug;
+
+ @Column(name = "website")
+ private String website;
+
+ @Column(name = "logo_url")
+ private String logoUrl;
+
+ @CreationTimestamp
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private Instant createdAt;
+
+ @UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
- @ColumnDefault("now()")
- @Column(name = "created_at", nullable = false)
- private Instant createdAt;
-
@Column(name = "deleted_at")
private Instant deletedAt;
- @ColumnDefault("gen_random_uuid()")
- @Column(name = "uuid")
- private UUID uuid;
-
- @Column(name = "url", length = Integer.MAX_VALUE)
- private String url;
-
- public String getUrl() {
- return url;
- }
-
- public void setUrl(String url) {
- this.url = url;
+ @PrePersist
+ public void prePersist() {
+ if (uuid == null) {
+ uuid = UUID.randomUUID();
+ }
+ if (slug == null && name != null) {
+ slug = name.toLowerCase().replace(" ", "-");
+ }
}
+ // Getters and Setters
public Integer getId() {
return id;
}
@@ -61,12 +68,36 @@ public class Brand {
this.name = name;
}
- public Instant getUpdatedAt() {
- return updatedAt;
+ public UUID getUuid() {
+ return uuid;
}
- public void setUpdatedAt(Instant updatedAt) {
- this.updatedAt = updatedAt;
+ public void setUuid(UUID uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getSlug() {
+ return slug;
+ }
+
+ public void setSlug(String slug) {
+ this.slug = slug;
+ }
+
+ public String getWebsite() {
+ return website;
+ }
+
+ public void setWebsite(String website) {
+ this.website = website;
+ }
+
+ public String getLogoUrl() {
+ return logoUrl;
+ }
+
+ public void setLogoUrl(String logoUrl) {
+ this.logoUrl = logoUrl;
}
public Instant getCreatedAt() {
@@ -77,6 +108,14 @@ public class Brand {
this.createdAt = createdAt;
}
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(Instant updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
public Instant getDeletedAt() {
return deletedAt;
}
@@ -84,13 +123,4 @@ public class Brand {
public void setDeletedAt(Instant deletedAt) {
this.deletedAt = deletedAt;
}
-
- public UUID getUuid() {
- return uuid;
- }
-
- public void setUuid(UUID uuid) {
- this.uuid = uuid;
- }
-
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/CategoryMapping.java b/src/main/java/group/goforward/ballistic/model/CategoryMapping.java
new file mode 100644
index 0000000..303fd85
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/model/CategoryMapping.java
@@ -0,0 +1,98 @@
+// src/main/java/group/goforward/ballistic/model/CategoryMapping.java
+package group.goforward.ballistic.model;
+
+import jakarta.persistence.*;
+import java.time.OffsetDateTime;
+
+@Entity
+@Table(name = "category_mappings")
+public class CategoryMapping {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id", nullable = false)
+ private Integer id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "merchant_id", nullable = false)
+ private Merchant merchant;
+
+ @Column(name = "raw_category_path", nullable = false)
+ private String rawCategoryPath;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "part_category_id")
+ private PartCategory partCategory;
+
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt = OffsetDateTime.now();
+
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt = OffsetDateTime.now();
+
+ @PrePersist
+ public void onCreate() {
+ OffsetDateTime now = OffsetDateTime.now();
+ if (createdAt == null) {
+ createdAt = now;
+ }
+ if (updatedAt == null) {
+ updatedAt = now;
+ }
+ }
+
+ @PreUpdate
+ public void onUpdate() {
+ this.updatedAt = OffsetDateTime.now();
+ }
+
+ // --- getters & setters ---
+
+ public Integer getId() {
+ return id;
+ }
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public Merchant getMerchant() {
+ return merchant;
+ }
+
+ public void setMerchant(Merchant merchant) {
+ this.merchant = merchant;
+ }
+
+ public String getRawCategoryPath() {
+ return rawCategoryPath;
+ }
+
+ public void setRawCategoryPath(String rawCategoryPath) {
+ this.rawCategoryPath = rawCategoryPath;
+ }
+
+ public PartCategory getPartCategory() {
+ return partCategory;
+ }
+
+ public void setPartCategory(PartCategory partCategory) {
+ this.partCategory = partCategory;
+ }
+
+ public OffsetDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(OffsetDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public OffsetDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(OffsetDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/FeedImport.java b/src/main/java/group/goforward/ballistic/model/FeedImport.java
index a117543..3b84d22 100644
--- a/src/main/java/group/goforward/ballistic/model/FeedImport.java
+++ b/src/main/java/group/goforward/ballistic/model/FeedImport.java
@@ -1,28 +1,27 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
-import org.hibernate.annotations.ColumnDefault;
-import java.time.OffsetDateTime;
+import java.time.Instant;
@Entity
@Table(name = "feed_imports")
public class FeedImport {
+
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
- @Column(name = "id", nullable = false)
private Long id;
- @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ // merchant_id
+ @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant;
- @ColumnDefault("now()")
@Column(name = "started_at", nullable = false)
- private OffsetDateTime startedAt;
+ private Instant startedAt;
@Column(name = "finished_at")
- private OffsetDateTime finishedAt;
+ private Instant finishedAt;
@Column(name = "rows_total")
private Integer rowsTotal;
@@ -36,21 +35,18 @@ public class FeedImport {
@Column(name = "rows_updated")
private Integer rowsUpdated;
- @ColumnDefault("'running'")
- @Column(name = "status", nullable = false, length = Integer.MAX_VALUE)
- private String status;
+ @Column(name = "status", nullable = false)
+ private String status = "running";
- @Column(name = "error_message", length = Integer.MAX_VALUE)
+ @Column(name = "error_message")
private String errorMessage;
+ // getters & setters
+
public Long getId() {
return id;
}
- public void setId(Long id) {
- this.id = id;
- }
-
public Merchant getMerchant() {
return merchant;
}
@@ -59,19 +55,19 @@ public class FeedImport {
this.merchant = merchant;
}
- public OffsetDateTime getStartedAt() {
+ public Instant getStartedAt() {
return startedAt;
}
- public void setStartedAt(OffsetDateTime startedAt) {
+ public void setStartedAt(Instant startedAt) {
this.startedAt = startedAt;
}
- public OffsetDateTime getFinishedAt() {
+ public Instant getFinishedAt() {
return finishedAt;
}
- public void setFinishedAt(OffsetDateTime finishedAt) {
+ public void setFinishedAt(Instant finishedAt) {
this.finishedAt = finishedAt;
}
@@ -122,5 +118,4 @@ public class FeedImport {
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
-
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/Merchant.java b/src/main/java/group/goforward/ballistic/model/Merchant.java
index fe43897..a8fb407 100644
--- a/src/main/java/group/goforward/ballistic/model/Merchant.java
+++ b/src/main/java/group/goforward/ballistic/model/Merchant.java
@@ -8,6 +8,7 @@ import java.time.OffsetDateTime;
@Entity
@Table(name = "merchants")
public class Merchant {
+
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
@@ -22,9 +23,18 @@ public class Merchant {
@Column(name = "feed_url", nullable = false, length = Integer.MAX_VALUE)
private String feedUrl;
+ @Column(name = "offer_feed_url")
+ private String offerFeedUrl;
+
+ @Column(name = "last_full_import_at")
+ private OffsetDateTime lastFullImportAt;
+
+ @Column(name = "last_offer_sync_at")
+ private OffsetDateTime lastOfferSyncAt;
+
@ColumnDefault("true")
@Column(name = "is_active", nullable = false)
- private Boolean isActive = false;
+ private Boolean isActive = true;
@ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
@@ -34,6 +44,10 @@ public class Merchant {
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
+ // -----------------------
+ // GETTERS & SETTERS
+ // -----------------------
+
public Integer getId() {
return id;
}
@@ -66,12 +80,36 @@ public class Merchant {
this.feedUrl = feedUrl;
}
+ public String getOfferFeedUrl() {
+ return offerFeedUrl;
+ }
+
+ public void setOfferFeedUrl(String offerFeedUrl) {
+ this.offerFeedUrl = offerFeedUrl;
+ }
+
+ public OffsetDateTime getLastFullImportAt() {
+ return lastFullImportAt;
+ }
+
+ public void setLastFullImportAt(OffsetDateTime lastFullImportAt) {
+ this.lastFullImportAt = lastFullImportAt;
+ }
+
+ public OffsetDateTime getLastOfferSyncAt() {
+ return lastOfferSyncAt;
+ }
+
+ public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) {
+ this.lastOfferSyncAt = lastOfferSyncAt;
+ }
+
public Boolean getIsActive() {
return isActive;
}
- public void setIsActive(Boolean isActive) {
- this.isActive = isActive;
+ public void setIsActive(Boolean active) {
+ this.isActive = active;
}
public OffsetDateTime getCreatedAt() {
@@ -89,5 +127,4 @@ public class Merchant {
public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = updatedAt;
}
-
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java
new file mode 100644
index 0000000..3b2338a
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/model/MerchantCategoryMapping.java
@@ -0,0 +1,105 @@
+package group.goforward.ballistic.model;
+
+import jakarta.persistence.*;
+import java.time.OffsetDateTime;
+
+import group.goforward.ballistic.model.ProductConfiguration;
+
+@Entity
+@Table(
+ name = "merchant_category_mappings",
+ uniqueConstraints = @UniqueConstraint(
+ name = "uq_merchant_category",
+ columnNames = { "merchant_id", "raw_category" }
+ )
+)
+public class MerchantCategoryMapping {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY) // SERIAL
+ @Column(name = "id", nullable = false)
+ private Integer id;
+
+ @ManyToOne(fetch = FetchType.LAZY, optional = false)
+ @JoinColumn(name = "merchant_id", nullable = false)
+ private Merchant merchant;
+
+ @Column(name = "raw_category", nullable = false, length = 512)
+ private String rawCategory;
+
+ @Column(name = "mapped_part_role", length = 128)
+ private String mappedPartRole; // e.g. "upper-receiver", "barrel"
+
+ @Column(name = "mapped_configuration")
+ @Enumerated(EnumType.STRING)
+ private ProductConfiguration mappedConfiguration;
+
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt = OffsetDateTime.now();
+
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt = OffsetDateTime.now();
+
+ @PreUpdate
+ public void onUpdate() {
+ this.updatedAt = OffsetDateTime.now();
+ }
+
+ // getters & setters
+
+ public Integer getId() {
+ return id;
+ }
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public Merchant getMerchant() {
+ return merchant;
+ }
+
+ public void setMerchant(Merchant merchant) {
+ this.merchant = merchant;
+ }
+
+ public String getRawCategory() {
+ return rawCategory;
+ }
+
+ public void setRawCategory(String rawCategory) {
+ this.rawCategory = rawCategory;
+ }
+
+ public String getMappedPartRole() {
+ return mappedPartRole;
+ }
+
+ public void setMappedPartRole(String mappedPartRole) {
+ this.mappedPartRole = mappedPartRole;
+ }
+
+ public ProductConfiguration getMappedConfiguration() {
+ return mappedConfiguration;
+ }
+
+ public void setMappedConfiguration(ProductConfiguration mappedConfiguration) {
+ this.mappedConfiguration = mappedConfiguration;
+ }
+
+ public OffsetDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(OffsetDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public OffsetDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(OffsetDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/PartCategory.java b/src/main/java/group/goforward/ballistic/model/PartCategory.java
index 79cd38d..a129b37 100644
--- a/src/main/java/group/goforward/ballistic/model/PartCategory.java
+++ b/src/main/java/group/goforward/ballistic/model/PartCategory.java
@@ -1,24 +1,49 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
+import org.hibernate.annotations.ColumnDefault;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
@Entity
@Table(name = "part_categories")
public class PartCategory {
+
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
private Integer id;
- @Column(name = "slug", nullable = false, length = Integer.MAX_VALUE)
+ @Column(name = "slug", nullable = false, unique = true)
private String slug;
- @Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
+ @Column(name = "name", nullable = false)
private String name;
- @Column(name = "description", length = Integer.MAX_VALUE)
+ @Column(name = "description")
private String description;
+ @ColumnDefault("gen_random_uuid()")
+ @Column(name = "uuid", nullable = false)
+ private UUID uuid;
+
+ @Column(name = "group_name")
+ private String groupName;
+
+ @Column(name = "sort_order")
+ private Integer sortOrder;
+
+ @ColumnDefault("now()")
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt;
+
+ @ColumnDefault("now()")
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt;
+
+ // --- Getters & Setters ---
+
public Integer getId() {
return id;
}
@@ -51,4 +76,43 @@ public class PartCategory {
this.description = description;
}
+ public UUID getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(UUID uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getGroupName() {
+ return groupName;
+ }
+
+ public void setGroupName(String groupName) {
+ this.groupName = groupName;
+ }
+
+ public Integer getSortOrder() {
+ return sortOrder;
+ }
+
+ public void setSortOrder(Integer sortOrder) {
+ this.sortOrder = sortOrder;
+ }
+
+ public OffsetDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(OffsetDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public OffsetDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(OffsetDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/PartRoleCategoryMapping.java b/src/main/java/group/goforward/ballistic/model/PartRoleCategoryMapping.java
new file mode 100644
index 0000000..07bdea8
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/model/PartRoleCategoryMapping.java
@@ -0,0 +1,56 @@
+package group.goforward.ballistic.model;
+
+import jakarta.persistence.*;
+import java.time.OffsetDateTime;
+
+@Entity
+@Table(name = "part_role_category_mappings",
+ uniqueConstraints = @UniqueConstraint(columnNames = {"platform", "part_role"}))
+public class PartRoleCategoryMapping {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Integer id;
+
+ @Column(name = "platform", nullable = false)
+ private String platform;
+
+ @Column(name = "part_role", nullable = false)
+ private String partRole;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "category_slug", referencedColumnName = "slug", nullable = false)
+ private PartCategory category;
+
+ @Column(name = "notes")
+ private String notes;
+
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt;
+
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt;
+
+ // getters/setters…
+
+ public Integer getId() { return id; }
+ public void setId(Integer id) { this.id = id; }
+
+ public String getPlatform() { return platform; }
+ public void setPlatform(String platform) { this.platform = platform; }
+
+ public String getPartRole() { return partRole; }
+ public void setPartRole(String partRole) { this.partRole = partRole; }
+
+ public PartCategory getCategory() { return category; }
+ public void setCategory(PartCategory category) { this.category = category; }
+
+ public String getNotes() { return notes; }
+ public void setNotes(String notes) { this.notes = notes; }
+
+ public OffsetDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(OffsetDateTime createdAt) { this.createdAt = createdAt; }
+
+ public OffsetDateTime getUpdatedAt() { return updatedAt; }
+ public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java b/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java
new file mode 100644
index 0000000..d336815
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/model/PartRoleMapping.java
@@ -0,0 +1,65 @@
+package group.goforward.ballistic.model;
+
+import jakarta.persistence.*;
+
+@Entity
+@Table(name = "part_role_mappings")
+public class PartRoleMapping {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Integer id;
+
+ @Column(nullable = false)
+ private String platform; // e.g. "AR-15"
+
+ @Column(name = "part_role", nullable = false)
+ private String partRole; // e.g. "UPPER", "BARREL", etc.
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "part_category_id")
+ private PartCategory partCategory;
+
+ @Column(columnDefinition = "text")
+ private String notes;
+
+ public Integer getId() {
+ return id;
+ }
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public String getPlatform() {
+ return platform;
+ }
+
+ public void setPlatform(String platform) {
+ this.platform = platform;
+ }
+
+ public String getPartRole() {
+ return partRole;
+ }
+
+ public void setPartRole(String partRole) {
+ this.partRole = partRole;
+ }
+
+ public PartCategory getPartCategory() {
+ return partCategory;
+ }
+
+ public void setPartCategory(PartCategory partCategory) {
+ this.partCategory = partCategory;
+ }
+
+ public String getNotes() {
+ return notes;
+ }
+
+ public void setNotes(String notes) {
+ this.notes = notes;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/PriceHistory.java b/src/main/java/group/goforward/ballistic/model/PriceHistory.java
index 9f9eeb6..2266b26 100644
--- a/src/main/java/group/goforward/ballistic/model/PriceHistory.java
+++ b/src/main/java/group/goforward/ballistic/model/PriceHistory.java
@@ -1,41 +1,35 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
-import org.hibernate.annotations.ColumnDefault;
-import org.hibernate.annotations.OnDelete;
-import org.hibernate.annotations.OnDeleteAction;
import java.math.BigDecimal;
-import java.time.OffsetDateTime;
+import java.time.Instant;
@Entity
@Table(name = "price_history")
public class PriceHistory {
+
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
- @Column(name = "id", nullable = false)
private Long id;
- @ManyToOne(fetch = FetchType.LAZY, optional = false)
- @OnDelete(action = OnDeleteAction.CASCADE)
+ // product_offer_id
+ @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_offer_id", nullable = false)
private ProductOffer productOffer;
@Column(name = "price", nullable = false, precision = 10, scale = 2)
private BigDecimal price;
- @ColumnDefault("now()")
@Column(name = "recorded_at", nullable = false)
- private OffsetDateTime recordedAt;
+ private Instant recordedAt;
+
+ // getters & setters
public Long getId() {
return id;
}
- public void setId(Long id) {
- this.id = id;
- }
-
public ProductOffer getProductOffer() {
return productOffer;
}
@@ -52,12 +46,11 @@ public class PriceHistory {
this.price = price;
}
- public OffsetDateTime getRecordedAt() {
+ public Instant getRecordedAt() {
return recordedAt;
}
- public void setRecordedAt(OffsetDateTime recordedAt) {
+ public void setRecordedAt(Instant recordedAt) {
this.recordedAt = recordedAt;
}
-
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/Product.java b/src/main/java/group/goforward/ballistic/model/Product.java
index 5c49905..816099f 100644
--- a/src/main/java/group/goforward/ballistic/model/Product.java
+++ b/src/main/java/group/goforward/ballistic/model/Product.java
@@ -1,185 +1,154 @@
package group.goforward.ballistic.model;
import jakarta.persistence.*;
-import org.hibernate.annotations.ColumnDefault;
-
-import java.math.BigDecimal;
import java.time.Instant;
+import java.util.UUID;
+import java.math.BigDecimal;
+import java.util.Comparator;
+import java.util.Objects;
+import java.util.Set;
+import java.util.HashSet;
+
+import group.goforward.ballistic.model.ProductOffer;
+import group.goforward.ballistic.model.ProductConfiguration;
@Entity
@Table(name = "products")
+@NamedQuery(name="Products.findByPlatformWithBrand", query= "" +
+ "SELECT p FROM Product p" +
+ " JOIN FETCH p.brand b" +
+ " WHERE p.platform = :platform" +
+ " AND p.deletedAt IS NULL")
+
+@NamedQuery(name="Product.findByPlatformAndPartRoleInWithBrand", query= "" +
+ "SELECT p FROM Product p JOIN FETCH p.brand b" +
+ " WHERE p.platform = :platform" +
+ " AND p.partRole IN :roles" +
+ " AND p.deletedAt IS NULL")
+
+@NamedQuery(name="Product.findProductsbyBrandByOffers", query="" +
+ " SELECT DISTINCT p FROM Product p" +
+ " LEFT JOIN FETCH p.brand b" +
+ " LEFT JOIN FETCH p.offers o" +
+ " WHERE p.platform = :platform" +
+ " AND p.deletedAt IS NULL")
+
public class Product {
+
@Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY) // uses products_id_seq in Postgres
@Column(name = "id", nullable = false)
private Integer id;
+ @Column(name = "uuid", nullable = false, updatable = false)
+ private UUID uuid;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "brand_id", nullable = false)
+ private Brand brand;
+
@Column(name = "name", nullable = false)
private String name;
- @Column(name = "description", nullable = false, length = Integer.MAX_VALUE)
+ @Column(name = "slug", nullable = false)
+ private String slug;
+
+ @Column(name = "mpn")
+ private String mpn;
+
+ @Column(name = "upc")
+ private String upc;
+
+ @Column(name = "platform")
+ private String platform;
+
+ @Column(name = "part_role")
+ private String partRole;
+
+ @Column(name = "configuration")
+ @Enumerated(EnumType.STRING)
+ private ProductConfiguration configuration;
+
+ @Column(name = "short_description")
+ private String shortDescription;
+
+ @Column(name = "description")
private String description;
- @Column(name = "price", nullable = false)
- private BigDecimal price;
+ @Column(name = "main_image_url")
+ private String mainImageUrl;
- @Column(name = "reseller_id", nullable = false)
- private Integer resellerId;
-
- @Column(name = "category_id", nullable = false)
- private Integer categoryId;
-
- @ColumnDefault("0")
- @Column(name = "stock_qty")
- private Integer stockQty;
-
- @ColumnDefault("now()")
- @Column(name = "updated_at", nullable = false)
- private Instant updatedAt;
-
- @ColumnDefault("now()")
@Column(name = "created_at", nullable = false)
private Instant createdAt;
+ @Column(name = "updated_at")
+ private Instant updatedAt;
+
@Column(name = "deleted_at")
private Instant deletedAt;
+
+ @Column(name = "raw_category_key")
+ private String rawCategoryKey;
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "brand_id")
- private Brand brand;
+ @Column(name = "platform_locked", nullable = false)
+ private Boolean platformLocked = false;
- @ManyToOne(fetch = FetchType.LAZY, optional = false)
- @JoinColumn(name = "part_category_id", nullable = false)
- private PartCategory partCategory;
+ @OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
+ private Set offers = new HashSet<>();
- @Column(name = "slug", nullable = false, length = Integer.MAX_VALUE)
- private String slug;
-
- @Column(name = "caliber", length = Integer.MAX_VALUE)
- private String caliber;
-
- @Column(name = "barrel_length_mm")
- private Integer barrelLengthMm;
-
- @Column(name = "gas_system", length = Integer.MAX_VALUE)
- private String gasSystem;
-
- @Column(name = "handguard_length_mm")
- private Integer handguardLengthMm;
-
- @Column(name = "image_url", length = Integer.MAX_VALUE)
- private String imageUrl;
-
- @Column(name = "spec_sheet_url", length = Integer.MAX_VALUE)
- private String specSheetUrl;
-
- @Column(name = "msrp", precision = 10, scale = 2)
- private BigDecimal msrp;
-
- @Column(name = "current_lowest_price", precision = 10, scale = 2)
- private BigDecimal currentLowestPrice;
-
- @ManyToOne(fetch = FetchType.LAZY)
- @JoinColumn(name = "current_lowest_merchant_id")
- private Merchant currentLowestMerchant;
-
- @ColumnDefault("true")
- @Column(name = "is_active", nullable = false)
- private Boolean isActive = false;
-
- public Boolean getIsActive() {
- return isActive;
+ public Set getOffers() {
+ return offers;
}
- public void setIsActive(Boolean isActive) {
- this.isActive = isActive;
+ public void setOffers(Set offers) {
+ this.offers = offers;
}
- public Merchant getCurrentLowestMerchant() {
- return currentLowestMerchant;
+ // --- lifecycle hooks ---
+
+ @PrePersist
+ public void prePersist() {
+ if (uuid == null) {
+ uuid = UUID.randomUUID();
+ }
+ Instant now = Instant.now();
+ if (createdAt == null) {
+ createdAt = now;
+ }
+ if (updatedAt == null) {
+ updatedAt = now;
+ }
}
- public void setCurrentLowestMerchant(Merchant currentLowestMerchant) {
- this.currentLowestMerchant = currentLowestMerchant;
+ @PreUpdate
+ public void preUpdate() {
+ updatedAt = Instant.now();
}
- public BigDecimal getCurrentLowestPrice() {
- return currentLowestPrice;
+ public String getRawCategoryKey() {
+ return rawCategoryKey;
}
- public void setCurrentLowestPrice(BigDecimal currentLowestPrice) {
- this.currentLowestPrice = currentLowestPrice;
+ public void setRawCategoryKey(String rawCategoryKey) {
+ this.rawCategoryKey = rawCategoryKey;
}
- public BigDecimal getMsrp() {
- return msrp;
+ // --- getters & setters ---
+
+ public Integer getId() {
+ return id;
}
- public void setMsrp(BigDecimal msrp) {
- this.msrp = msrp;
+ public void setId(Integer id) {
+ this.id = id;
}
- public String getSpecSheetUrl() {
- return specSheetUrl;
+ public UUID getUuid() {
+ return uuid;
}
- public void setSpecSheetUrl(String specSheetUrl) {
- this.specSheetUrl = specSheetUrl;
- }
-
- public String getImageUrl() {
- return imageUrl;
- }
-
- public void setImageUrl(String imageUrl) {
- this.imageUrl = imageUrl;
- }
-
- public Integer getHandguardLengthMm() {
- return handguardLengthMm;
- }
-
- public void setHandguardLengthMm(Integer handguardLengthMm) {
- this.handguardLengthMm = handguardLengthMm;
- }
-
- public String getGasSystem() {
- return gasSystem;
- }
-
- public void setGasSystem(String gasSystem) {
- this.gasSystem = gasSystem;
- }
-
- public Integer getBarrelLengthMm() {
- return barrelLengthMm;
- }
-
- public void setBarrelLengthMm(Integer barrelLengthMm) {
- this.barrelLengthMm = barrelLengthMm;
- }
-
- public String getCaliber() {
- return caliber;
- }
-
- public void setCaliber(String caliber) {
- this.caliber = caliber;
- }
-
- public String getSlug() {
- return slug;
- }
-
- public void setSlug(String slug) {
- this.slug = slug;
- }
-
- public PartCategory getPartCategory() {
- return partCategory;
- }
-
- public void setPartCategory(PartCategory partCategory) {
- this.partCategory = partCategory;
+ public void setUuid(UUID uuid) {
+ this.uuid = uuid;
}
public Brand getBrand() {
@@ -190,14 +159,6 @@ public class Product {
this.brand = brand;
}
- public Integer getId() {
- return id;
- }
-
- public void setId(Integer id) {
- this.id = id;
- }
-
public String getName() {
return name;
}
@@ -206,6 +167,54 @@ public class Product {
this.name = name;
}
+ public String getSlug() {
+ return slug;
+ }
+
+ public void setSlug(String slug) {
+ this.slug = slug;
+ }
+
+ public String getMpn() {
+ return mpn;
+ }
+
+ public void setMpn(String mpn) {
+ this.mpn = mpn;
+ }
+
+ public String getUpc() {
+ return upc;
+ }
+
+ public void setUpc(String upc) {
+ this.upc = upc;
+ }
+
+ public String getPlatform() {
+ return platform;
+ }
+
+ public void setPlatform(String platform) {
+ this.platform = platform;
+ }
+
+ public String getPartRole() {
+ return partRole;
+ }
+
+ public void setPartRole(String partRole) {
+ this.partRole = partRole;
+ }
+
+ public String getShortDescription() {
+ return shortDescription;
+ }
+
+ public void setShortDescription(String shortDescription) {
+ this.shortDescription = shortDescription;
+ }
+
public String getDescription() {
return description;
}
@@ -214,44 +223,12 @@ public class Product {
this.description = description;
}
- public BigDecimal getPrice() {
- return price;
+ public String getMainImageUrl() {
+ return mainImageUrl;
}
- public void setPrice(BigDecimal price) {
- this.price = price;
- }
-
- public Integer getResellerId() {
- return resellerId;
- }
-
- public void setResellerId(Integer resellerId) {
- this.resellerId = resellerId;
- }
-
- public Integer getCategoryId() {
- return categoryId;
- }
-
- public void setCategoryId(Integer categoryId) {
- this.categoryId = categoryId;
- }
-
- public Integer getStockQty() {
- return stockQty;
- }
-
- public void setStockQty(Integer stockQty) {
- this.stockQty = stockQty;
- }
-
- public Instant getUpdatedAt() {
- return updatedAt;
- }
-
- public void setUpdatedAt(Instant updatedAt) {
- this.updatedAt = updatedAt;
+ public void setMainImageUrl(String mainImageUrl) {
+ this.mainImageUrl = mainImageUrl;
}
public Instant getCreatedAt() {
@@ -262,6 +239,14 @@ public class Product {
this.createdAt = createdAt;
}
+ public Instant getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(Instant updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
public Instant getDeletedAt() {
return deletedAt;
}
@@ -270,4 +255,56 @@ public class Product {
this.deletedAt = deletedAt;
}
-}
\ No newline at end of file
+ public Boolean getPlatformLocked() {
+ return platformLocked;
+ }
+
+ public void setPlatformLocked(Boolean platformLocked) {
+ this.platformLocked = platformLocked;
+ }
+
+ public ProductConfiguration getConfiguration() {
+ return configuration;
+ }
+
+ public void setConfiguration(ProductConfiguration configuration) {
+ this.configuration = configuration;
+ }
+ // Convenience: best offer price for Gunbuilder
+public BigDecimal getBestOfferPrice() {
+ if (offers == null || offers.isEmpty()) {
+ return BigDecimal.ZERO;
+ }
+
+ return offers.stream()
+ // pick sale_price if present, otherwise retail_price
+ .map(offer -> {
+ if (offer.getSalePrice() != null) {
+ return offer.getSalePrice();
+ }
+ return offer.getRetailPrice();
+ })
+ .filter(Objects::nonNull)
+ .min(BigDecimal::compareTo)
+ .orElse(BigDecimal.ZERO);
+}
+
+ // Convenience: URL for the best-priced offer
+ public String getBestOfferBuyUrl() {
+ if (offers == null || offers.isEmpty()) {
+ return null;
+ }
+
+ return offers.stream()
+ .sorted(Comparator.comparing(offer -> {
+ if (offer.getSalePrice() != null) {
+ return offer.getSalePrice();
+ }
+ return offer.getRetailPrice();
+ }, Comparator.nullsLast(BigDecimal::compareTo)))
+ .map(ProductOffer::getAffiliateUrl)
+ .filter(Objects::nonNull)
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git a/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java
new file mode 100644
index 0000000..2c8c7d8
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/model/ProductConfiguration.java
@@ -0,0 +1,10 @@
+package group.goforward.ballistic.model;
+
+public enum ProductConfiguration {
+ STRIPPED, // bare receiver / component
+ ASSEMBLED, // built up but not fully complete
+ BARRELED, // upper + barrel + gas system, no BCG/CH
+ COMPLETE, // full assembly ready to run
+ KIT, // collection of parts (LPK, trigger kits, etc.)
+ OTHER // fallback / unknown
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/ProductOffer.java b/src/main/java/group/goforward/ballistic/model/ProductOffer.java
index 64ec40b..dace70a 100644
--- a/src/main/java/group/goforward/ballistic/model/ProductOffer.java
+++ b/src/main/java/group/goforward/ballistic/model/ProductOffer.java
@@ -7,15 +7,15 @@ import org.hibernate.annotations.OnDeleteAction;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
-import java.util.UUID;
@Entity
@Table(name = "product_offers")
public class ProductOffer {
+
@Id
- @GeneratedValue(strategy = GenerationType.AUTO)
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", nullable = false)
- private UUID id;
+ private Integer id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@OnDelete(action = OnDeleteAction.CASCADE)
@@ -26,16 +26,16 @@ public class ProductOffer {
@JoinColumn(name = "merchant_id", nullable = false)
private Merchant merchant;
- @Column(name = "avantlink_product_id", nullable = false, length = Integer.MAX_VALUE)
+ @Column(name = "avantlink_product_id", nullable = false)
private String avantlinkProductId;
- @Column(name = "sku", length = Integer.MAX_VALUE)
+ @Column(name = "sku")
private String sku;
- @Column(name = "upc", length = Integer.MAX_VALUE)
+ @Column(name = "upc")
private String upc;
- @Column(name = "buy_url", nullable = false, length = Integer.MAX_VALUE)
+ @Column(name = "buy_url", nullable = false)
private String buyUrl;
@Column(name = "price", nullable = false, precision = 10, scale = 2)
@@ -45,7 +45,7 @@ public class ProductOffer {
private BigDecimal originalPrice;
@ColumnDefault("'USD'")
- @Column(name = "currency", nullable = false, length = Integer.MAX_VALUE)
+ @Column(name = "currency", nullable = false)
private String currency;
@ColumnDefault("true")
@@ -60,11 +60,15 @@ public class ProductOffer {
@Column(name = "first_seen_at", nullable = false)
private OffsetDateTime firstSeenAt;
- public UUID getId() {
+ // -----------------------------------------------------
+ // Getters & setters
+ // -----------------------------------------------------
+
+ public Integer getId() {
return id;
}
- public void setId(UUID id) {
+ public void setId(Integer id) {
this.id = id;
}
@@ -164,4 +168,26 @@ public class ProductOffer {
this.firstSeenAt = firstSeenAt;
}
+ // -----------------------------------------------------
+ // Helper Methods (used by Product entity)
+ // -----------------------------------------------------
+
+ public BigDecimal getSalePrice() {
+ return price;
+ }
+
+ public BigDecimal getRetailPrice() {
+ return originalPrice != null ? originalPrice : price;
+ }
+
+ public String getAffiliateUrl() {
+ return buyUrl;
+ }
+
+ public BigDecimal getEffectivePrice() {
+ if (price != null && originalPrice != null && price.compareTo(originalPrice) < 0) {
+ return price;
+ }
+ return price != null ? price : originalPrice;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/User.java b/src/main/java/group/goforward/ballistic/model/User.java
index ac49425..fa3661a 100644
--- a/src/main/java/group/goforward/ballistic/model/User.java
+++ b/src/main/java/group/goforward/ballistic/model/User.java
@@ -1,298 +1,222 @@
-package group.goforward.ballistic.model;
-
-import jakarta.persistence.Column;
-import jakarta.persistence.Entity;
-import jakarta.persistence.Id;
-import jakarta.persistence.Table;
-import org.hibernate.annotations.ColumnDefault;
-
-import java.time.Instant;
-import java.time.LocalDate;
-import java.util.UUID;
-
-@Entity
-@Table(name = "users")
-public class User {
- @Id
- @Column(name = "id", nullable = false, length = 21)
- private String id;
-
- @Column(name = "name", length = Integer.MAX_VALUE)
- private String name;
-
- @Column(name = "username", length = 50)
- private String username;
-
- @Column(name = "email", nullable = false)
- private String email;
-
- @Column(name = "first_name", length = 50)
- private String firstName;
-
- @Column(name = "last_name", length = 50)
- private String lastName;
-
- @Column(name = "full_name", length = 50)
- private String fullName;
-
- @Column(name = "profile_picture")
- private String profilePicture;
-
- @Column(name = "image", length = Integer.MAX_VALUE)
- private String image;
-
- @Column(name = "date_of_birth")
- private LocalDate dateOfBirth;
-
- @Column(name = "phone_number", length = 20)
- private String phoneNumber;
-
- @ColumnDefault("CURRENT_TIMESTAMP")
- @Column(name = "created_at")
- private Instant createdAt;
-
- @ColumnDefault("CURRENT_TIMESTAMP")
- @Column(name = "updated_at")
- private Instant updatedAt;
-
- @ColumnDefault("false")
- @Column(name = "is_admin")
- private Boolean isAdmin;
-
- @Column(name = "last_login")
- private Instant lastLogin;
-
- @ColumnDefault("false")
- @Column(name = "email_verified", nullable = false)
- private Boolean emailVerified = false;
-
- @ColumnDefault("'public'")
- @Column(name = "build_privacy_setting", length = Integer.MAX_VALUE)
- private String buildPrivacySetting;
-
- @ColumnDefault("gen_random_uuid()")
- @Column(name = "uuid")
- private UUID uuid;
-
- @Column(name = "discord_id")
- private String discordId;
-
- @Column(name = "hashed_password")
- private String hashedPassword;
-
- @Column(name = "avatar")
- private String avatar;
-
- @Column(name = "stripe_subscription_id", length = 191)
- private String stripeSubscriptionId;
-
- @Column(name = "stripe_price_id", length = 191)
- private String stripePriceId;
-
- @Column(name = "stripe_customer_id", length = 191)
- private String stripeCustomerId;
-
- @Column(name = "stripe_current_period_end")
- private Instant stripeCurrentPeriodEnd;
-
- public String getId() {
- return id;
- }
-
- public void setId(String id) {
- this.id = id;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(String name) {
- this.name = name;
- }
-
- public String getUsername() {
- return username;
- }
-
- public void setUsername(String username) {
- this.username = username;
- }
-
- public String getEmail() {
- return email;
- }
-
- public void setEmail(String email) {
- this.email = email;
- }
-
- public String getFirstName() {
- return firstName;
- }
-
- public void setFirstName(String firstName) {
- this.firstName = firstName;
- }
-
- public String getLastName() {
- return lastName;
- }
-
- public void setLastName(String lastName) {
- this.lastName = lastName;
- }
-
- public String getFullName() {
- return fullName;
- }
-
- public void setFullName(String fullName) {
- this.fullName = fullName;
- }
-
- public String getProfilePicture() {
- return profilePicture;
- }
-
- public void setProfilePicture(String profilePicture) {
- this.profilePicture = profilePicture;
- }
-
- public String getImage() {
- return image;
- }
-
- public void setImage(String image) {
- this.image = image;
- }
-
- public LocalDate getDateOfBirth() {
- return dateOfBirth;
- }
-
- public void setDateOfBirth(LocalDate dateOfBirth) {
- this.dateOfBirth = dateOfBirth;
- }
-
- public String getPhoneNumber() {
- return phoneNumber;
- }
-
- public void setPhoneNumber(String phoneNumber) {
- this.phoneNumber = phoneNumber;
- }
-
- public Instant getCreatedAt() {
- return createdAt;
- }
-
- public void setCreatedAt(Instant createdAt) {
- this.createdAt = createdAt;
- }
-
- public Instant getUpdatedAt() {
- return updatedAt;
- }
-
- public void setUpdatedAt(Instant updatedAt) {
- this.updatedAt = updatedAt;
- }
-
- public Boolean getIsAdmin() {
- return isAdmin;
- }
-
- public void setIsAdmin(Boolean isAdmin) {
- this.isAdmin = isAdmin;
- }
-
- public Instant getLastLogin() {
- return lastLogin;
- }
-
- public void setLastLogin(Instant lastLogin) {
- this.lastLogin = lastLogin;
- }
-
- public Boolean getEmailVerified() {
- return emailVerified;
- }
-
- public void setEmailVerified(Boolean emailVerified) {
- this.emailVerified = emailVerified;
- }
-
- public String getBuildPrivacySetting() {
- return buildPrivacySetting;
- }
-
- public void setBuildPrivacySetting(String buildPrivacySetting) {
- this.buildPrivacySetting = buildPrivacySetting;
- }
-
- public UUID getUuid() {
- return uuid;
- }
-
- public void setUuid(UUID uuid) {
- this.uuid = uuid;
- }
-
- public String getDiscordId() {
- return discordId;
- }
-
- public void setDiscordId(String discordId) {
- this.discordId = discordId;
- }
-
- public String getHashedPassword() {
- return hashedPassword;
- }
-
- public void setHashedPassword(String hashedPassword) {
- this.hashedPassword = hashedPassword;
- }
-
- public String getAvatar() {
- return avatar;
- }
-
- public void setAvatar(String avatar) {
- this.avatar = avatar;
- }
-
- public String getStripeSubscriptionId() {
- return stripeSubscriptionId;
- }
-
- public void setStripeSubscriptionId(String stripeSubscriptionId) {
- this.stripeSubscriptionId = stripeSubscriptionId;
- }
-
- public String getStripePriceId() {
- return stripePriceId;
- }
-
- public void setStripePriceId(String stripePriceId) {
- this.stripePriceId = stripePriceId;
- }
-
- public String getStripeCustomerId() {
- return stripeCustomerId;
- }
-
- public void setStripeCustomerId(String stripeCustomerId) {
- this.stripeCustomerId = stripeCustomerId;
- }
-
- public Instant getStripeCurrentPeriodEnd() {
- return stripeCurrentPeriodEnd;
- }
-
- public void setStripeCurrentPeriodEnd(Instant stripeCurrentPeriodEnd) {
- this.stripeCurrentPeriodEnd = stripeCurrentPeriodEnd;
- }
-
+package group.goforward.ballistic.model;
+
+import jakarta.persistence.*;
+import jakarta.validation.constraints.NotNull;
+import org.hibernate.annotations.ColumnDefault;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+@Entity
+@Table(name = "users")
+public class User {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "id", nullable = false)
+ private Integer id;
+
+ @NotNull
+ @ColumnDefault("gen_random_uuid()")
+ @Column(name = "uuid", nullable = false)
+ private UUID uuid;
+
+ @NotNull
+ @Column(name = "email", nullable = false, length = Integer.MAX_VALUE)
+ private String email;
+
+ @NotNull
+ @Column(name = "password_hash", nullable = false, length = Integer.MAX_VALUE)
+ private String passwordHash;
+
+ @Column(name = "display_name", length = Integer.MAX_VALUE)
+ private String displayName;
+
+ @NotNull
+ @ColumnDefault("'USER'")
+ @Column(name = "role", nullable = false, length = Integer.MAX_VALUE)
+ private String role;
+
+ @NotNull
+ @ColumnDefault("true")
+ @Column(name = "is_active", nullable = false)
+ private boolean isActive = true;
+
+ @NotNull
+ @ColumnDefault("now()")
+ @Column(name = "created_at", nullable = false)
+ private OffsetDateTime createdAt;
+
+ @NotNull
+ @ColumnDefault("now()")
+ @Column(name = "updated_at", nullable = false)
+ private OffsetDateTime updatedAt;
+
+ @Column(name = "deleted_at")
+ private OffsetDateTime deletedAt;
+
+ // NEW FIELDS
+
+ @Column(name = "email_verified_at")
+ private OffsetDateTime emailVerifiedAt;
+
+ @Column(name = "verification_token", length = Integer.MAX_VALUE)
+ private String verificationToken;
+
+ @Column(name = "reset_password_token", length = Integer.MAX_VALUE)
+ private String resetPasswordToken;
+
+ @Column(name = "reset_password_expires_at")
+ private OffsetDateTime resetPasswordExpiresAt;
+
+ @Column(name = "last_login_at")
+ private OffsetDateTime lastLoginAt;
+
+ @ColumnDefault("0")
+ @Column(name = "login_count", nullable = false)
+ private Integer loginCount = 0;
+
+ // --- Getters / setters ---
+
+ public Integer getId() {
+ return id;
+ }
+
+ public void setId(Integer id) {
+ this.id = id;
+ }
+
+ public UUID getUuid() {
+ return uuid;
+ }
+
+ public void setUuid(UUID uuid) {
+ this.uuid = uuid;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getPasswordHash() {
+ return passwordHash;
+ }
+
+ public void setPasswordHash(String passwordHash) {
+ this.passwordHash = passwordHash;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public void setDisplayName(String displayName) {
+ this.displayName = displayName;
+ }
+
+ public String getRole() {
+ return role;
+ }
+
+ public void setRole(String role) {
+ this.role = role;
+ }
+
+ public boolean getIsActive() {
+ return isActive;
+ }
+
+ public void setIsActive(boolean active) {
+ isActive = active;
+ }
+
+ public OffsetDateTime getCreatedAt() {
+ return createdAt;
+ }
+
+ public void setCreatedAt(OffsetDateTime createdAt) {
+ this.createdAt = createdAt;
+ }
+
+ public OffsetDateTime getUpdatedAt() {
+ return updatedAt;
+ }
+
+ public void setUpdatedAt(OffsetDateTime updatedAt) {
+ this.updatedAt = updatedAt;
+ }
+
+ public OffsetDateTime getDeletedAt() {
+ return deletedAt;
+ }
+
+ public void setDeletedAt(OffsetDateTime deletedAt) {
+ this.deletedAt = deletedAt;
+ }
+
+ public OffsetDateTime getEmailVerifiedAt() {
+ return emailVerifiedAt;
+ }
+
+ public void setEmailVerifiedAt(OffsetDateTime emailVerifiedAt) {
+ this.emailVerifiedAt = emailVerifiedAt;
+ }
+
+ public String getVerificationToken() {
+ return verificationToken;
+ }
+
+ public void setVerificationToken(String verificationToken) {
+ this.verificationToken = verificationToken;
+ }
+
+ public String getResetPasswordToken() {
+ return resetPasswordToken;
+ }
+
+ public void setResetPasswordToken(String resetPasswordToken) {
+ this.resetPasswordToken = resetPasswordToken;
+ }
+
+ public OffsetDateTime getResetPasswordExpiresAt() {
+ return resetPasswordExpiresAt;
+ }
+
+ public void setResetPasswordExpiresAt(OffsetDateTime resetPasswordExpiresAt) {
+ this.resetPasswordExpiresAt = resetPasswordExpiresAt;
+ }
+
+ public OffsetDateTime getLastLoginAt() {
+ return lastLoginAt;
+ }
+
+ public void setLastLoginAt(OffsetDateTime lastLoginAt) {
+ this.lastLoginAt = lastLoginAt;
+ }
+
+ public Integer getLoginCount() {
+ return loginCount;
+ }
+
+ public void setLoginCount(Integer loginCount) {
+ this.loginCount = loginCount;
+ }
+
+ // convenience helpers
+
+ @Transient
+ public boolean isEmailVerified() {
+ return emailVerifiedAt != null;
+ }
+
+ public void incrementLoginCount() {
+ if (loginCount == null) {
+ loginCount = 0;
+ }
+ loginCount++;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/package-info.java b/src/main/java/group/goforward/ballistic/model/package-info.java
new file mode 100644
index 0000000..002a39a
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/model/package-info.java
@@ -0,0 +1 @@
+package group.goforward.ballistic.model;
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/model/AccountRepository.java b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java
similarity index 62%
rename from src/main/java/group/goforward/ballistic/model/AccountRepository.java
rename to src/main/java/group/goforward/ballistic/repos/AccountRepository.java
index abb4486..dcf38ae 100644
--- a/src/main/java/group/goforward/ballistic/model/AccountRepository.java
+++ b/src/main/java/group/goforward/ballistic/repos/AccountRepository.java
@@ -1,5 +1,6 @@
-package group.goforward.ballistic.model;
+package group.goforward.ballistic.repos;
+import group.goforward.ballistic.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
diff --git a/src/main/java/group/goforward/ballistic/repos/BrandRepository.java b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java
new file mode 100644
index 0000000..58b63cc
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/BrandRepository.java
@@ -0,0 +1,8 @@
+package group.goforward.ballistic.repos;
+import group.goforward.ballistic.model.Brand;
+import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Optional;
+
+public interface BrandRepository extends JpaRepository {
+ Optional findByNameIgnoreCase(String name);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java
new file mode 100644
index 0000000..bd9c97f
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/BuildItemRepository.java
@@ -0,0 +1,12 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.BuildsComponent;
+import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface BuildItemRepository extends JpaRepository {
+ List findByBuildId(Integer buildId);
+ Optional findByUuid(UUID uuid);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/BuildRepository.java b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java
new file mode 100644
index 0000000..a849ed8
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/BuildRepository.java
@@ -0,0 +1,10 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.Build;
+import org.springframework.data.jpa.repository.JpaRepository;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface BuildRepository extends JpaRepository {
+ Optional findByUuid(UUID uuid);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java
new file mode 100644
index 0000000..b6132c0
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/CategoryMappingRepository.java
@@ -0,0 +1,22 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.CategoryMapping;
+import group.goforward.ballistic.model.Merchant;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.List;
+
+public interface CategoryMappingRepository extends JpaRepository {
+
+ // All mappings for a merchant, ordered nicely
+ List findByMerchantIdOrderByRawCategoryPathAsc(Integer merchantId);
+
+ // Merchants that actually have mappings (for the dropdown)
+ @Query("""
+ select distinct cm.merchant
+ from CategoryMapping cm
+ order by cm.merchant.name asc
+ """)
+ List findDistinctMerchantsWithMappings();
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java
new file mode 100644
index 0000000..90adae7
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/FeedImportRepository.java
@@ -0,0 +1,7 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.FeedImport;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface FeedImportRepository extends JpaRepository {
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java
new file mode 100644
index 0000000..f26eca3
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/MerchantCategoryMappingRepository.java
@@ -0,0 +1,17 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.MerchantCategoryMapping;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MerchantCategoryMappingRepository
+ extends JpaRepository {
+
+ Optional findByMerchantIdAndRawCategoryIgnoreCase(
+ Integer merchantId,
+ String rawCategory
+ );
+
+ List findByMerchantIdOrderByRawCategoryAsc(Integer merchantId);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java
new file mode 100644
index 0000000..853687f
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/MerchantRepository.java
@@ -0,0 +1,11 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.Merchant;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface MerchantRepository extends JpaRepository {
+
+ Optional findByNameIgnoreCase(String name);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java
new file mode 100644
index 0000000..aff326e
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/PartCategoryRepository.java
@@ -0,0 +1,14 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.PartCategory;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface PartCategoryRepository extends JpaRepository {
+
+ Optional findBySlug(String slug);
+
+ List findAllByOrderByGroupNameAscSortOrderAscNameAsc();
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/PartRoleCategoryMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/PartRoleCategoryMappingRepository.java
new file mode 100644
index 0000000..eef8305
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/PartRoleCategoryMappingRepository.java
@@ -0,0 +1,14 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.PartRoleCategoryMapping;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface PartRoleCategoryMappingRepository extends JpaRepository {
+
+ List findAllByPlatformOrderByPartRoleAsc(String platform);
+
+ Optional findByPlatformAndPartRole(String platform, String partRole);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java b/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java
new file mode 100644
index 0000000..b64889e
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/PartRoleMappingRepository.java
@@ -0,0 +1,12 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.PartRoleMapping;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.List;
+
+public interface PartRoleMappingRepository extends JpaRepository {
+
+ // List mappings for a platform, ordered nicely for the UI
+ List findByPlatformOrderByPartRoleAsc(String platform);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java
new file mode 100644
index 0000000..2a8423b
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/PriceHistoryRepository.java
@@ -0,0 +1,7 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.PriceHistory;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PriceHistoryRepository extends JpaRepository {
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java
new file mode 100644
index 0000000..6178413
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/ProductOfferRepository.java
@@ -0,0 +1,22 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.ProductOffer;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+
+public interface ProductOfferRepository extends JpaRepository {
+
+ List findByProductId(Integer productId);
+
+ // Used by the /api/products/gunbuilder endpoint
+ List findByProductIdIn(Collection productIds);
+
+ // Unique offer lookup for importer upsert
+ Optional findByMerchantIdAndAvantlinkProductId(
+ Integer merchantId,
+ String avantlinkProductId
+ );
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/ProductRepository.java b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
new file mode 100644
index 0000000..ad91c1e
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/ProductRepository.java
@@ -0,0 +1,65 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.Brand;
+import group.goforward.ballistic.model.Product;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+
+import java.util.List;
+
+public interface ProductRepository extends JpaRepository {
+
+ // -------------------------------------------------
+ // Used by MerchantFeedImportServiceImpl
+ // -------------------------------------------------
+
+ List findAllByBrandAndMpn(Brand brand, String mpn);
+
+ List findAllByBrandAndUpc(Brand brand, String upc);
+
+ boolean existsBySlug(String slug);
+
+ // -------------------------------------------------
+ // Used by ProductController for platform views
+ // -------------------------------------------------
+
+ @Query("""
+ SELECT p
+ FROM Product p
+ JOIN FETCH p.brand b
+ WHERE p.platform = :platform
+ AND p.deletedAt IS NULL
+ """)
+ List findByPlatformWithBrand(@Param("platform") String platform);
+
+@Query(name="Products.findByPlatformWithBrand")
+List findByPlatformWithBrandNQ(@Param("platform") String platform);
+
+ @Query("""
+ SELECT p
+ FROM Product p
+ JOIN FETCH p.brand b
+ WHERE p.platform = :platform
+ AND p.partRole IN :roles
+ AND p.deletedAt IS NULL
+ """)
+ List findByPlatformAndPartRoleInWithBrand(
+ @Param("platform") String platform,
+ @Param("roles") List roles
+ );
+
+ // -------------------------------------------------
+ // Used by Gunbuilder service (if you wired this)
+ // -------------------------------------------------
+
+ @Query("""
+ SELECT DISTINCT p
+ FROM Product p
+ LEFT JOIN FETCH p.brand b
+ LEFT JOIN FETCH p.offers o
+ WHERE p.platform = :platform
+ AND p.deletedAt IS NULL
+ """)
+ List findSomethingForGunbuilder(@Param("platform") String platform);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/UserRepository.java b/src/main/java/group/goforward/ballistic/repos/UserRepository.java
new file mode 100644
index 0000000..28f7ba4
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/UserRepository.java
@@ -0,0 +1,16 @@
+package group.goforward.ballistic.repos;
+
+import group.goforward.ballistic.model.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface UserRepository extends JpaRepository {
+
+ Optional findByEmailIgnoreCaseAndDeletedAtIsNull(String email);
+
+ boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
+
+ Optional findByUuid(UUID uuid);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/repos/package-info.java b/src/main/java/group/goforward/ballistic/repos/package-info.java
new file mode 100644
index 0000000..c278bd2
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/repos/package-info.java
@@ -0,0 +1,13 @@
+/**
+ * Provides the classes necessary for the Spring Repository for the ballistic -Builder application.
+ * This package includes Repository for Spring-Boot application
+ *
+ *
+ * The main entry point for managing the inventory is the
+ * {@link group.goforward.ballistic.BallisticApplication} class.
+ *
+ * @since 1.0
+ * @author Sean Strawsburg
+ * @version 1.1
+ */
+package group.goforward.ballistic.repos;
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/security/CustomUserDetails.java b/src/main/java/group/goforward/ballistic/security/CustomUserDetails.java
new file mode 100644
index 0000000..94604ff
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/security/CustomUserDetails.java
@@ -0,0 +1,59 @@
+package group.goforward.ballistic.security;
+
+import group.goforward.ballistic.model.User;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.userdetails.UserDetails;
+
+import java.util.Collection;
+import java.util.List;
+
+public class CustomUserDetails implements UserDetails {
+
+ private final User user;
+ private final List authorities;
+
+ public CustomUserDetails(User user) {
+ this.user = user;
+ this.authorities = List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole()));
+ }
+
+ public User getUser() {
+ return user;
+ }
+
+ @Override
+ public Collection extends GrantedAuthority> getAuthorities() {
+ return authorities;
+ }
+
+ @Override
+ public String getPassword() {
+ return user.getPasswordHash();
+ }
+
+ @Override
+ public String getUsername() {
+ return user.getEmail();
+ }
+
+ @Override
+ public boolean isAccountNonExpired() {
+ return user.getDeletedAt() == null;
+ }
+
+ @Override
+ public boolean isAccountNonLocked() {
+ return user.getIsActive();
+ }
+
+ @Override
+ public boolean isCredentialsNonExpired() {
+ return user.getDeletedAt() == null;
+ }
+
+ @Override
+ public boolean isEnabled() {
+ return user.getIsActive() && user.getDeletedAt() == null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/security/CustomUserDetailsService.java b/src/main/java/group/goforward/ballistic/security/CustomUserDetailsService.java
new file mode 100644
index 0000000..93efe9f
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/security/CustomUserDetailsService.java
@@ -0,0 +1,25 @@
+package group.goforward.ballistic.security;
+
+import group.goforward.ballistic.model.User;
+import group.goforward.ballistic.repos.UserRepository;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.stereotype.Service;
+
+@Service
+public class CustomUserDetailsService implements UserDetailsService {
+
+ private final UserRepository users;
+
+ public CustomUserDetailsService(UserRepository users) {
+ this.users = users;
+ }
+
+ @Override
+ public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
+ User user = users.findByEmailIgnoreCaseAndDeletedAtIsNull(email)
+ .orElseThrow(() -> new UsernameNotFoundException("User not found"));
+ return new CustomUserDetails(user);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/security/JwtAuthenticationEntryPoint.java b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationEntryPoint.java
new file mode 100644
index 0000000..f9ff76b
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationEntryPoint.java
@@ -0,0 +1,26 @@
+package group.goforward.ballistic.security;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+
+@Component
+public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
+
+ @Override
+ public void commence(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ AuthenticationException authException
+ ) throws IOException, ServletException {
+ // Simple JSON 401 response
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setContentType("application/json");
+ response.getWriter().write("{\"error\":\"Unauthorized\"}");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/security/JwtAuthenticationFilter.java b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationFilter.java
new file mode 100644
index 0000000..6a8d70b
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/security/JwtAuthenticationFilter.java
@@ -0,0 +1,80 @@
+package group.goforward.ballistic.security;
+
+import group.goforward.ballistic.model.User;
+import group.goforward.ballistic.repos.UserRepository;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.UUID;
+
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtService jwtService;
+ private final UserRepository userRepository;
+
+ public JwtAuthenticationFilter(JwtService jwtService, UserRepository userRepository) {
+ this.jwtService = jwtService;
+ this.userRepository = userRepository;
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain
+ ) throws ServletException, IOException {
+
+ String authHeader = request.getHeader("Authorization");
+
+ if (!StringUtils.hasText(authHeader) || !authHeader.startsWith("Bearer ")) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ String token = authHeader.substring(7);
+
+ if (!jwtService.isTokenValid(token)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ UUID userUuid = jwtService.extractUserUuid(token);
+
+ if (userUuid == null || SecurityContextHolder.getContext().getAuthentication() != null) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ User user = userRepository.findByUuid(userUuid)
+ .orElse(null);
+
+ if (user == null || !user.getIsActive()) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ CustomUserDetails userDetails = new CustomUserDetails(user);
+
+ UsernamePasswordAuthenticationToken authToken =
+ new UsernamePasswordAuthenticationToken(
+ userDetails,
+ null,
+ userDetails.getAuthorities()
+ );
+ authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+
+ SecurityContextHolder.getContext().setAuthentication(authToken);
+
+ filterChain.doFilter(request, response);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/security/JwtService.java b/src/main/java/group/goforward/ballistic/security/JwtService.java
new file mode 100644
index 0000000..9cef8d0
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/security/JwtService.java
@@ -0,0 +1,71 @@
+package group.goforward.ballistic.security;
+
+import group.goforward.ballistic.model.User;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.security.Keys;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.security.Key;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.Map;
+import java.util.UUID;
+
+@Service
+public class JwtService {
+
+ private final Key key;
+ private final long accessTokenMinutes;
+
+ public JwtService(
+ @Value("${security.jwt.secret}") String secret,
+ @Value("${security.jwt.access-token-minutes:60}") long accessTokenMinutes
+ ) {
+ this.key = Keys.hmacShaKeyFor(secret.getBytes());
+ this.accessTokenMinutes = accessTokenMinutes;
+ }
+
+ public String generateToken(User user) {
+ Instant now = Instant.now();
+ Instant expiry = now.plus(accessTokenMinutes, ChronoUnit.MINUTES);
+
+ return Jwts.builder()
+ .setSubject(user.getUuid().toString())
+ .setIssuedAt(Date.from(now))
+ .setExpiration(Date.from(expiry))
+ .addClaims(Map.of(
+ "email", user.getEmail(),
+ "role", user.getRole(),
+ "displayName", user.getDisplayName()
+ ))
+ .signWith(key, SignatureAlgorithm.HS256)
+ .compact();
+ }
+
+ public UUID extractUserUuid(String token) {
+ Claims claims = parseClaims(token);
+ return UUID.fromString(claims.getSubject());
+ }
+
+ public boolean isTokenValid(String token) {
+ try {
+ parseClaims(token);
+ return true;
+ } catch (JwtException | IllegalArgumentException ex) {
+ return false;
+ }
+ }
+
+ private Claims parseClaims(String token) {
+ return Jwts.parserBuilder()
+ .setSigningKey(key)
+ .build()
+ .parseClaimsJws(token)
+ .getBody();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/BrandService.java b/src/main/java/group/goforward/ballistic/services/BrandService.java
new file mode 100644
index 0000000..4039db4
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/BrandService.java
@@ -0,0 +1,16 @@
+package group.goforward.ballistic.services;
+
+import group.goforward.ballistic.model.Brand;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface BrandService {
+
+ List findAll();
+
+ Optional findById(Integer id);
+
+ Brand save(Brand item);
+ void deleteById(Integer id);
+}
diff --git a/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java
new file mode 100644
index 0000000..1e074fe
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/GunbuilderProductService.java
@@ -0,0 +1,57 @@
+package group.goforward.ballistic.services;
+
+import group.goforward.ballistic.model.PartCategory;
+import group.goforward.ballistic.model.Product;
+import group.goforward.ballistic.repos.ProductRepository;
+import group.goforward.ballistic.web.dto.GunbuilderProductDto;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class GunbuilderProductService {
+
+ private final ProductRepository productRepository;
+ private final PartCategoryResolverService partCategoryResolverService;
+
+ public GunbuilderProductService(
+ ProductRepository productRepository,
+ PartCategoryResolverService partCategoryResolverService
+ ) {
+ this.productRepository = productRepository;
+ this.partCategoryResolverService = partCategoryResolverService;
+ }
+
+ public List listGunbuilderProducts(String platform) {
+
+ List products = productRepository.findSomethingForGunbuilder(platform);
+
+ return products.stream()
+ .map(p -> {
+ var maybeCategory = partCategoryResolverService
+ .resolveForPlatformAndPartRole(platform, p.getPartRole());
+
+ if (maybeCategory.isEmpty()) {
+ // you can also log here
+ return null;
+ }
+
+ PartCategory cat = maybeCategory.get();
+
+ return new GunbuilderProductDto(
+ p.getId(),
+ p.getName(),
+ p.getBrand().getName(),
+ platform,
+ p.getPartRole(),
+ p.getBestOfferPrice(),
+ p.getMainImageUrl(),
+ p.getBestOfferBuyUrl(),
+ cat.getSlug(),
+ cat.getGroupName()
+ );
+ })
+ .filter(dto -> dto != null)
+ .toList();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java
new file mode 100644
index 0000000..ec963df
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/MerchantCategoryMappingService.java
@@ -0,0 +1,96 @@
+package group.goforward.ballistic.services;
+
+import group.goforward.ballistic.model.Merchant;
+import group.goforward.ballistic.model.MerchantCategoryMapping;
+import group.goforward.ballistic.model.ProductConfiguration;
+import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
+import jakarta.transaction.Transactional;
+import java.util.List;
+import java.util.Optional;
+import org.springframework.stereotype.Service;
+
+@Service
+public class MerchantCategoryMappingService {
+
+ private final MerchantCategoryMappingRepository mappingRepository;
+
+ public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) {
+ this.mappingRepository = mappingRepository;
+ }
+
+ public List findByMerchant(Integer merchantId) {
+ return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId);
+ }
+
+ /**
+ * Resolve (or create) a mapping row for this merchant + raw category.
+ * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set).
+ * - If it doesn't exist, creates a placeholder row with null mappings and returns it.
+ *
+ * The importer can then:
+ * - skip rows where mappedPartRole is still null
+ * - use mappedConfiguration if present
+ */
+ @Transactional
+ public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) {
+ if (rawCategory == null || rawCategory.isBlank()) {
+ return null;
+ }
+
+ String trimmed = rawCategory.trim();
+
+ return mappingRepository
+ .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
+ .orElseGet(() -> {
+ MerchantCategoryMapping mapping = new MerchantCategoryMapping();
+ mapping.setMerchant(merchant);
+ mapping.setRawCategory(trimmed);
+ mapping.setMappedPartRole(null);
+ mapping.setMappedConfiguration(null);
+ return mappingRepository.save(mapping);
+ });
+ }
+
+ /**
+ * Upsert mapping (admin UI).
+ */
+ @Transactional
+ public MerchantCategoryMapping upsertMapping(
+ Merchant merchant,
+ String rawCategory,
+ String mappedPartRole,
+ ProductConfiguration mappedConfiguration
+ ) {
+ String trimmed = rawCategory.trim();
+
+ MerchantCategoryMapping mapping = mappingRepository
+ .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
+ .orElseGet(() -> {
+ MerchantCategoryMapping m = new MerchantCategoryMapping();
+ m.setMerchant(merchant);
+ m.setRawCategory(trimmed);
+ return m;
+ });
+
+ mapping.setMappedPartRole(
+ (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim()
+ );
+
+ mapping.setMappedConfiguration(mappedConfiguration);
+
+ return mappingRepository.save(mapping);
+ }
+ /**
+ * Backwards-compatible overload for existing callers (e.g. controller)
+ * that don’t care about productConfiguration yet.
+ */
+ @Transactional
+ public MerchantCategoryMapping upsertMapping(
+ Merchant merchant,
+ String rawCategory,
+ String mappedPartRole
+ ) {
+ // Delegate to the new method with `null` configuration
+ return upsertMapping(merchant, rawCategory, mappedPartRole, null);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java
new file mode 100644
index 0000000..399c448
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java
@@ -0,0 +1,14 @@
+package group.goforward.ballistic.services;
+
+public interface MerchantFeedImportService {
+
+ /**
+ * Full product + offer import for a given merchant.
+ */
+ void importMerchantFeed(Integer merchantId);
+
+ /**
+ * Offers-only sync (price / stock) for a given merchant.
+ */
+ void syncOffersOnly(Integer merchantId);
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java
new file mode 100644
index 0000000..31dd63a
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/PartCategoryResolverService.java
@@ -0,0 +1,41 @@
+package group.goforward.ballistic.services;
+
+import group.goforward.ballistic.model.PartCategory;
+import group.goforward.ballistic.repos.PartCategoryRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.Optional;
+
+@Service
+public class PartCategoryResolverService {
+
+ private final PartCategoryRepository partCategoryRepository;
+
+ public PartCategoryResolverService(PartCategoryRepository partCategoryRepository) {
+ this.partCategoryRepository = partCategoryRepository;
+ }
+
+ /**
+ * Resolve the canonical PartCategory for a given platform + partRole.
+ *
+ * For now we keep it simple:
+ * - We treat partRole as the slug (e.g. "barrel", "upper", "trigger").
+ * - Normalize to lower-kebab (spaces -> dashes, lowercased).
+ * - Look up by slug in part_categories.
+ *
+ * Later, if we want per-merchant / per-platform overrides using category_mappings,
+ * we can extend this method without changing callers.
+ */
+ public Optional resolveForPlatformAndPartRole(String platform, String partRole) {
+ if (partRole == null || partRole.isBlank()) {
+ return Optional.empty();
+ }
+
+ String normalizedSlug = partRole
+ .trim()
+ .toLowerCase()
+ .replace(" ", "-");
+
+ return partCategoryRepository.findBySlug(normalizedSlug);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/group/goforward/ballistic/services/PsaService.java b/src/main/java/group/goforward/ballistic/services/PsaService.java
new file mode 100644
index 0000000..ecaa265
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/PsaService.java
@@ -0,0 +1,17 @@
+package group.goforward.ballistic.services;
+
+import group.goforward.ballistic.model.Psa;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+public interface PsaService {
+ List findAll();
+
+ Optional findById(UUID id);
+
+ Psa save(Psa psa);
+
+ void deleteById(UUID id);
+}
diff --git a/src/main/java/group/goforward/ballistic/service/StatesService.java b/src/main/java/group/goforward/ballistic/services/StatesService.java
similarity index 81%
rename from src/main/java/group/goforward/ballistic/service/StatesService.java
rename to src/main/java/group/goforward/ballistic/services/StatesService.java
index f3a290a..e07d927 100644
--- a/src/main/java/group/goforward/ballistic/service/StatesService.java
+++ b/src/main/java/group/goforward/ballistic/services/StatesService.java
@@ -1,4 +1,4 @@
-package group.goforward.ballistic.service;
+package group.goforward.ballistic.services;
import group.goforward.ballistic.model.State;
diff --git a/src/main/java/group/goforward/ballistic/services/UsersService.java b/src/main/java/group/goforward/ballistic/services/UsersService.java
new file mode 100644
index 0000000..3717947
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/UsersService.java
@@ -0,0 +1,16 @@
+package group.goforward.ballistic.services;
+
+import group.goforward.ballistic.model.User;
+
+import java.util.List;
+import java.util.Optional;
+
+public interface UsersService {
+
+ List findAll();
+
+ Optional findById(Integer id);
+
+ User save(User item);
+ void deleteById(Integer id);
+}
diff --git a/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java
new file mode 100644
index 0000000..fbd67b7
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/impl/BrandServiceImpl.java
@@ -0,0 +1,38 @@
+package group.goforward.ballistic.services.impl;
+
+
+import group.goforward.ballistic.model.Brand;
+import group.goforward.ballistic.repos.BrandRepository;
+import group.goforward.ballistic.services.BrandService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Optional;
+
+@Service
+public class BrandServiceImpl implements BrandService {
+
+ @Autowired
+ private BrandRepository repo;
+
+ @Override
+ public List findAll() {
+ return repo.findAll();
+ }
+
+ @Override
+ public Optional findById(Integer id) {
+ return repo.findById(id);
+ }
+
+ @Override
+ public Brand save(Brand item) {
+ return null;
+ }
+
+ @Override
+ public void deleteById(Integer id) {
+ deleteById(id);
+ }
+}
diff --git a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java
new file mode 100644
index 0000000..c2357a4
--- /dev/null
+++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java
@@ -0,0 +1,660 @@
+package group.goforward.ballistic.services.impl;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.HashMap;
+import java.io.Reader;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import org.springframework.cache.annotation.CacheEvict;
+
+import group.goforward.ballistic.imports.MerchantFeedRow;
+import group.goforward.ballistic.services.MerchantFeedImportService;
+import org.apache.commons.csv.CSVFormat;
+import org.apache.commons.csv.CSVParser;
+import org.apache.commons.csv.CSVRecord;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import group.goforward.ballistic.model.Brand;
+import group.goforward.ballistic.model.Merchant;
+import group.goforward.ballistic.model.Product;
+import group.goforward.ballistic.repos.BrandRepository;
+import group.goforward.ballistic.repos.MerchantRepository;
+import group.goforward.ballistic.repos.ProductRepository;
+import group.goforward.ballistic.services.MerchantCategoryMappingService;
+import group.goforward.ballistic.model.MerchantCategoryMapping;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import group.goforward.ballistic.repos.ProductOfferRepository;
+import group.goforward.ballistic.model.ProductOffer;
+
+import java.time.OffsetDateTime;
+
+@Service
+@Transactional
+public class MerchantFeedImportServiceImpl implements MerchantFeedImportService {
+ private static final Logger log = LoggerFactory.getLogger(MerchantFeedImportServiceImpl.class);
+
+ private final MerchantRepository merchantRepository;
+ private final BrandRepository brandRepository;
+ private final ProductRepository productRepository;
+ private final MerchantCategoryMappingService merchantCategoryMappingService;
+ private final ProductOfferRepository productOfferRepository;
+
+ public MerchantFeedImportServiceImpl(MerchantRepository merchantRepository,
+ BrandRepository brandRepository,
+ ProductRepository productRepository,
+ MerchantCategoryMappingService merchantCategoryMappingService,
+ ProductOfferRepository productOfferRepository) {
+ this.merchantRepository = merchantRepository;
+ this.brandRepository = brandRepository;
+ this.productRepository = productRepository;
+ this.merchantCategoryMappingService = merchantCategoryMappingService;
+ this.productOfferRepository = productOfferRepository;
+ }
+
+ @Override
+ @CacheEvict(value = "gunbuilderProducts", allEntries = true)
+ public void importMerchantFeed(Integer merchantId) {
+ log.info("Starting full import for merchantId={}", merchantId);
+
+ Merchant merchant = merchantRepository.findById(merchantId)
+ .orElseThrow(() -> new IllegalArgumentException("Merchant not found: " + merchantId));
+
+ // Read all rows from the merchant feed
+ List rows = readFeedRowsForMerchant(merchant);
+ log.info("Read {} feed rows for merchant={}", rows.size(), merchant.getName());
+
+ for (MerchantFeedRow row : rows) {
+ Brand brand = resolveBrand(row);
+ Product p = upsertProduct(merchant, brand, row);
+ log.debug("Upserted product id={}, name={}, slug={}, platform={}, partRole={}, merchant={}",
+ p.getId(), p.getName(), p.getSlug(), p.getPlatform(), p.getPartRole(), merchant.getName());
+ }
+ }
+
+ // ---------------------------------------------------------------------
+ // Upsert logic
+ // ---------------------------------------------------------------------
+
+ private Product upsertProduct(Merchant merchant, Brand brand, MerchantFeedRow row) {
+ log.debug("Upserting product for brand={}, sku={}, name={}", brand.getName(), row.sku(), row.productName());
+
+ String mpn = trimOrNull(row.manufacturerId());
+ String upc = trimOrNull(row.sku()); // placeholder until real UPC field
+
+ List candidates = Collections.emptyList();
+
+ if (mpn != null) {
+ candidates = productRepository.findAllByBrandAndMpn(brand, mpn);
+ }
+ if ((candidates == null || candidates.isEmpty()) && upc != null) {
+ candidates = productRepository.findAllByBrandAndUpc(brand, upc);
+ }
+
+ Product p;
+ boolean isNew = (candidates == null || candidates.isEmpty());
+
+ if (isNew) {
+ p = new Product();
+ p.setBrand(brand);
+ } else {
+ if (candidates.size() > 1) {
+ log.warn("Multiple existing products found for brand={}, mpn={}, upc={}. Using first match id={}",
+ brand.getName(), mpn, upc, candidates.get(0).getId());
+ }
+ p = candidates.get(0);
+ }
+
+ updateProductFromRow(p, merchant, row, isNew);
+
+ // Save the product first
+ Product saved = productRepository.save(p);
+
+ // Then upsert the offer for this row
+ upsertOfferFromRow(saved, merchant, row);
+
+ return saved;
+ }
+ private List