diff --git a/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java b/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java deleted file mode 100644 index af9de5e..0000000 --- a/src/main/java/group/goforward/ballistic/controllers/AdminMerchantController.java +++ /dev/null @@ -1,24 +0,0 @@ -package group.goforward.ballistic.controllers; - -import group.goforward.ballistic.model.Merchant; -import group.goforward.ballistic.repos.MerchantRepository; -import java.util.List; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/admin/merchants") -@CrossOrigin // adjust later if you want -public class AdminMerchantController { - - private final MerchantRepository merchantRepository; - - public AdminMerchantController(MerchantRepository merchantRepository) { - this.merchantRepository = merchantRepository; - } - - @GetMapping - public List getMerchants() { - // If you want a DTO here, you can wrap it, but this is fine for internal admin - return merchantRepository.findAll(); - } -} \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/ImportController.java b/src/main/java/group/goforward/ballistic/controllers/ImportController.java index b1ea350..996e460 100644 --- a/src/main/java/group/goforward/ballistic/controllers/ImportController.java +++ b/src/main/java/group/goforward/ballistic/controllers/ImportController.java @@ -6,17 +6,34 @@ import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/admin/imports") +@CrossOrigin(origins = "http://localhost:3000") public class ImportController { - private final MerchantFeedImportService importService; + private final MerchantFeedImportService merchantFeedImportService; - public ImportController(MerchantFeedImportService importService) { - this.importService = importService; + public ImportController(MerchantFeedImportService merchantFeedImportService) { + this.merchantFeedImportService = merchantFeedImportService; } + /** + * Full product + offer import for a merchant. + * + * POST /admin/imports/{merchantId} + */ @PostMapping("/{merchantId}") public ResponseEntity importMerchant(@PathVariable Integer merchantId) { - importService.importMerchantFeed(merchantId); - return ResponseEntity.accepted().build(); + merchantFeedImportService.importMerchantFeed(merchantId); + return ResponseEntity.noContent().build(); + } + + /** + * Offers-only sync (price/stock) for a merchant. + * + * POST /admin/imports/{merchantId}/offers-only + */ + @PostMapping("/{merchantId}/offers-only") + public ResponseEntity syncOffersOnly(@PathVariable Integer merchantId) { + merchantFeedImportService.syncOffersOnly(merchantId); + return ResponseEntity.noContent().build(); } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java new file mode 100644 index 0000000..511ea9b --- /dev/null +++ b/src/main/java/group/goforward/ballistic/controllers/MerchantAdminController.java @@ -0,0 +1,63 @@ +// MerchantAdminController.java +package group.goforward.ballistic.controllers; + +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import group.goforward.ballistic.model.Merchant; +import group.goforward.ballistic.repos.MerchantRepository; +import group.goforward.ballistic.web.dto.MerchantAdminDto; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.List; + +@RestController +@RequestMapping("/admin/merchants") +@CrossOrigin(origins = "http://localhost:3000") // TEMP for Cross-Origin Bug +public class MerchantAdminController { + + private final MerchantRepository merchantRepository; + + public MerchantAdminController(MerchantRepository merchantRepository) { + this.merchantRepository = merchantRepository; + } + + @GetMapping + public List listMerchants() { + return merchantRepository.findAll().stream().map(this::toDto).toList(); + } + + @PutMapping("/{id}") + public MerchantAdminDto updateMerchant( + @PathVariable Integer id, + @RequestBody MerchantAdminDto payload + ) { + Merchant merchant = merchantRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + merchant.setFeedUrl(payload.getFeedUrl()); + merchant.setOfferFeedUrl(payload.getOfferFeedUrl()); + merchant.setIsActive(payload.getIsActive() != null ? payload.getIsActive() : true); + // don’t touch last* here; those are set by import jobs + + merchant = merchantRepository.save(merchant); + return toDto(merchant); + } + + private MerchantAdminDto toDto(Merchant m) { + MerchantAdminDto dto = new MerchantAdminDto(); + dto.setId(m.getId()); + dto.setName(m.getName()); + dto.setFeedUrl(m.getFeedUrl()); + dto.setOfferFeedUrl(m.getOfferFeedUrl()); + dto.setIsActive(m.getIsActive()); + dto.setLastFullImportAt(m.getLastFullImportAt()); + dto.setLastOfferSyncAt(m.getLastOfferSyncAt()); + return dto; + } +} \ 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/services/MerchantFeedImportService.java b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java index 33cd776..5fea407 100644 --- a/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java +++ b/src/main/java/group/goforward/ballistic/services/MerchantFeedImportService.java @@ -3,7 +3,12 @@ package group.goforward.ballistic.services; public interface MerchantFeedImportService { /** - * Import the feed for a given merchant id. + * 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/impl/MerchantFeedImportServiceImpl.java b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java index c56f021..5208769 100644 --- a/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java +++ b/src/main/java/group/goforward/ballistic/services/impl/MerchantFeedImportServiceImpl.java @@ -4,6 +4,8 @@ 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.InputStreamReader; import java.net.URL; @@ -123,7 +125,38 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return saved; } - + private List> fetchFeedRows(String feedUrl) { + System.out.println("OFFERS >>> reading offer feed from: " + feedUrl); + + List> rows = new ArrayList<>(); + + try (Reader reader = (feedUrl.startsWith("http://") || feedUrl.startsWith("https://")) + ? new InputStreamReader(new URL(feedUrl).openStream(), StandardCharsets.UTF_8) + : java.nio.file.Files.newBufferedReader(java.nio.file.Paths.get(feedUrl), StandardCharsets.UTF_8); + CSVParser parser = CSVFormat.DEFAULT + .withFirstRecordAsHeader() + .withIgnoreSurroundingSpaces() + .withTrim() + .parse(reader)) { + + // capture header names from the CSV + List headers = new ArrayList<>(parser.getHeaderMap().keySet()); + + for (CSVRecord rec : parser) { + Map row = new HashMap<>(); + for (String header : headers) { + row.put(header, rec.get(header)); + } + rows.add(row); + } + } catch (Exception ex) { + throw new RuntimeException("Failed to read offer feed from " + feedUrl, ex); + } + + System.out.println("OFFERS >>> parsed " + rows.size() + " offer rows"); + return rows; + } + private void updateProductFromRow(Product p, Merchant merchant, MerchantFeedRow row, boolean isNew) { // ---------- NAME ---------- String name = coalesce( @@ -465,4 +498,79 @@ public class MerchantFeedImportServiceImpl implements MerchantFeedImportService return "unknown"; } + public void syncOffersOnly(Integer merchantId) { + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new RuntimeException("Merchant not found")); + + if (Boolean.FALSE.equals(merchant.getIsActive())) { + return; + } + + // Use offerFeedUrl if present, else fall back to feedUrl + String feedUrl = merchant.getOfferFeedUrl() != null + ? merchant.getOfferFeedUrl() + : merchant.getFeedUrl(); + + if (feedUrl == null) { + throw new RuntimeException("No offer feed URL configured for merchant " + merchantId); + } + + List> rows = fetchFeedRows(feedUrl); + + for (Map row : rows) { + upsertOfferOnlyFromRow(merchant, row); + } + + merchant.setLastOfferSyncAt(OffsetDateTime.now()); + merchantRepository.save(merchant); + } + private void upsertOfferOnlyFromRow(Merchant merchant, Map row) { + // For the offer-only sync, we key offers by the same identifier we used when creating them. + // In the current AvantLink-style feed, that is the SKU column. + String avantlinkProductId = trimOrNull(row.get("SKU")); + if (avantlinkProductId == null || avantlinkProductId.isBlank()) { + return; + } + + // Find existing offer + ProductOffer offer = productOfferRepository + .findByMerchantIdAndAvantlinkProductId(merchant.getId(), avantlinkProductId) + .orElse(null); + + if (offer == null) { + // This is a *sync* pass, not full ETL – if we don't already have an offer, skip. + return; + } + + // Parse price fields (column names match the main product feed) + BigDecimal price = parseBigDecimal(row.get("Sale Price")); + BigDecimal originalPrice = parseBigDecimal(row.get("Retail Price")); + + // Update only *offer* fields – do not touch Product + offer.setPrice(price); + offer.setOriginalPrice(originalPrice); + offer.setInStock(parseInStock(row)); + + // Prefer a fresh Buy Link from the feed if present, otherwise keep existing + String newBuyUrl = trimOrNull(row.get("Buy Link")); + offer.setBuyUrl(coalesce(newBuyUrl, offer.getBuyUrl())); + + offer.setLastSeenAt(OffsetDateTime.now()); + + productOfferRepository.save(offer); + } + private Boolean parseInStock(Map row) { + String inStock = trimOrNull(row.get("In Stock")); + if (inStock == null) return Boolean.FALSE; + + String lower = inStock.toLowerCase(); + if (lower.contains("true") || lower.contains("yes") || lower.contains("1")) { + return Boolean.TRUE; + } + if (lower.contains("false") || lower.contains("no") || lower.contains("0")) { + return Boolean.FALSE; + } + + return Boolean.FALSE; + } } \ No newline at end of file diff --git a/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java new file mode 100644 index 0000000..26d6f6c --- /dev/null +++ b/src/main/java/group/goforward/ballistic/web/dto/MerchantAdminDto.java @@ -0,0 +1,70 @@ +// MerchantAdminDto.java +package group.goforward.ballistic.web.dto; + +import java.time.OffsetDateTime; + +public class MerchantAdminDto { + private Integer id; + private String name; + private String feedUrl; + private String offerFeedUrl; + private Boolean isActive; + private OffsetDateTime lastFullImportAt; + private OffsetDateTime lastOfferSyncAt; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getFeedUrl() { + return feedUrl; + } + + public void setFeedUrl(String feedUrl) { + this.feedUrl = feedUrl; + } + + public String getOfferFeedUrl() { + return offerFeedUrl; + } + + public void setOfferFeedUrl(String offerFeedUrl) { + this.offerFeedUrl = offerFeedUrl; + } + + public Boolean getIsActive() { + return isActive; + } + + public void setIsActive(Boolean isActive) { + this.isActive = isActive; + } + + public OffsetDateTime getLastFullImportAt() { + return lastFullImportAt; + } + + public void setLastFullImportAt(OffsetDateTime lastFullImportAt) { + this.lastFullImportAt = lastFullImportAt; + } + + public OffsetDateTime getLastOfferSyncAt() { + return lastOfferSyncAt; + } + + public void setLastOfferSyncAt(OffsetDateTime lastOfferSyncAt) { + this.lastOfferSyncAt = lastOfferSyncAt; + } +} \ No newline at end of file