ammocpr/lib/ammoprices/catalog.ex
Graham McIntire 16a28ac01c
Add Target Sports USA scraper, product filtering, and subsonic detection
- Add subsonic boolean field to products with composite filter indexes
- Add TextDetector utility for detecting subsonic/casing from product titles
- Wire TextDetector into SgAmmo (casing + subsonic) and LuckyGunner (subsonic + fallback casing)
- Extend latest_prices_for_caliber with casing, grain_weight, and subsonic filter options
- Add distinct_grain_weights_for_caliber and distinct_casings_for_caliber queries
- Add filter UI with toggleable casing, grain weight, and subsonic buttons
- Add grain weight column and subsonic badge to product table
- Refresh filter options on PubSub price updates for real-time UI
- Implement Target Sports USA scraper with 18 caliber mappings
- Display all prices in dollar format ($0.38 instead of 38¢)
2026-03-11 16:28:04 -05:00

115 lines
2.3 KiB
Elixir

defmodule Ammoprices.Catalog do
@moduledoc false
import Ecto.Query
alias Ammoprices.Catalog.Caliber
alias Ammoprices.Catalog.Product
alias Ammoprices.Catalog.Retailer
alias Ammoprices.Repo
# Retailers
def list_retailers do
Repo.all(Retailer)
end
def get_retailer!(id) do
Repo.get!(Retailer, id)
end
def get_retailer_by_slug!(slug) do
Repo.get_by!(Retailer, slug: slug)
end
def create_retailer(attrs) do
%Retailer{}
|> Retailer.changeset(attrs)
|> Repo.insert()
end
def update_retailer(%Retailer{} = retailer, attrs) do
retailer
|> Retailer.changeset(attrs)
|> Repo.update()
end
# Calibers
def list_calibers do
Repo.all(Caliber)
end
def list_calibers_by_category(category) do
Caliber
|> where([c], c.category == ^category)
|> Repo.all()
end
def get_caliber!(id) do
Repo.get!(Caliber, id)
end
def get_caliber_by_slug!(slug) do
Repo.get_by!(Caliber, slug: slug)
end
def create_caliber(attrs) do
%Caliber{}
|> Caliber.changeset(attrs)
|> Repo.insert()
end
# Products
def upsert_product(retailer_id, caliber_id, attrs) do
%Product{retailer_id: retailer_id, caliber_id: caliber_id}
|> Product.changeset(attrs)
|> Repo.insert(
on_conflict:
{:replace,
[
:title,
:brand,
:grain_weight,
:round_count,
:casing,
:condition,
:in_stock,
:subsonic,
:last_seen_at,
:updated_at
]},
conflict_target: [:retailer_id, :url],
returning: true
)
end
def list_products_for_caliber(caliber_id, opts \\ []) do
limit = Keyword.get(opts, :limit, 100)
Product
|> where([p], p.caliber_id == ^caliber_id)
|> limit(^limit)
|> Repo.all()
end
def distinct_grain_weights_for_caliber(caliber_id) do
Product
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], not is_nil(p.grain_weight))
|> distinct(true)
|> select([p], p.grain_weight)
|> order_by([p], asc: p.grain_weight)
|> Repo.all()
end
def distinct_casings_for_caliber(caliber_id) do
Product
|> where([p], p.caliber_id == ^caliber_id)
|> where([p], not is_nil(p.casing))
|> distinct(true)
|> select([p], p.casing)
|> order_by([p], asc: p.casing)
|> Repo.all()
end
end