diff --git a/assets/css/theme-layer2-attributes.css b/assets/css/theme-layer2-attributes.css index 9254010..c87a534 100644 --- a/assets/css/theme-layer2-attributes.css +++ b/assets/css/theme-layer2-attributes.css @@ -343,6 +343,11 @@ color: #ffffff; } +.badge-discontinued { + background-color: var(--t-text-tertiary, #737373); + color: #ffffff; +} + /* Product Card Images — mobile: swipe, desktop: hover crossfade */ .product-card-image-wrap { position: relative; diff --git a/docs/plans/provider-sync-enhancements.md b/docs/plans/provider-sync-enhancements.md new file mode 100644 index 0000000..65173be --- /dev/null +++ b/docs/plans/provider-sync-enhancements.md @@ -0,0 +1,212 @@ +# Provider Sync Enhancements + +> Status: Planned +> Tier: 3.5 (Business tools) + +Small improvements to provider sync for better visibility and resilience. Not versioning products (overkill), just better audit trail and handling of edge cases. + +--- + +## Overview + +Three enhancements to the provider sync system: + +1. **Detailed audit logging** — Field-level change tracking, not just "3 updated" +2. **Soft-delete for discontinued products** — Don't break carts/orders/links +3. **Variant-level availability** — Handle out of stock / discontinued variants +4. **Price change alerts** — Warn when margins might be affected + +--- + +## 1. Detailed sync audit logging + +Currently `sync.completed` logs "47 products, 3 updated" but doesn't capture *what* changed. Enhance to log field-level changes. + +**Implementation:** +```elixir +# In product sync, before update +def sync_product(product, provider_data) do + changes = diff_product(product, provider_data) + + if changes != %{} do + ActivityLog.log_event("sync.product_updated", + "#{product.title}: #{summarize_changes(changes)}", + payload: %{product_id: product.id, changes: changes} + ) + end + + update_product(product, provider_data) +end + +defp diff_product(current, new) do + fields = [:title, :description, :base_price, :status] + Enum.reduce(fields, %{}, fn field, acc -> + old_val = Map.get(current, field) + new_val = Map.get(new, field) + if old_val != new_val, do: Map.put(acc, field, {old_val, new_val}), else: acc + end) +end + +defp summarize_changes(changes) do + changes + |> Map.keys() + |> Enum.map(&to_string/1) + |> Enum.join(", ") + # => "title, base_price" +end +``` + +**Activity log entries:** +- `sync.product_updated` — "Awesome T-Shirt: title, base_price changed" +- `sync.product_added` — "New product: Cool Hoodie" +- `sync.product_removed` — "Product removed: Old Mug" (if provider removes it) + +--- + +## 2. Soft-delete for discontinued products + +When a product disappears from the provider catalogue (sync returns 404 or product not in list), don't hard-delete it. Set `status: "discontinued"` instead. + +**Why:** +- Existing carts might have this product +- Order history references it +- Links from external sites won't 404 immediately + +**Implementation:** +```elixir +# products schema +field :status, :string, default: "active" # "active", "discontinued", "draft" + +# In sync +def handle_missing_product(product) do + Products.update_product(product, %{status: "discontinued"}) + ActivityLog.log_event("sync.product_discontinued", + "#{product.title} no longer available from provider", + level: "warning", + payload: %{product_id: product.id} + ) +end +``` + +**UI behaviour:** +- Discontinued products hidden from shop browse/search +- Product page shows "This product is no longer available" +- Cart shows warning if item is discontinued +- Admin can see discontinued products in a filtered view + +--- + +## 2b. Variant-level availability + +Variants can go out of stock or be discontinued independently of the product. This matters because: +- A customer may have "Blue / Large" in their cart when that specific variant becomes unavailable +- The product is still active, just that combination isn't + +**Variant statuses:** +```elixir +# variants schema +field :availability, :string, default: "available" +# "available", "out_of_stock", "discontinued" +``` + +**Sync handling:** +```elixir +def sync_variant(variant, provider_data) do + new_availability = case provider_data do + %{is_available: false, is_enabled: false} -> "discontinued" + %{is_available: false} -> "out_of_stock" + %{is_available: true} -> "available" + end + + if variant.availability != new_availability do + Products.update_variant(variant, %{availability: new_availability}) + + if new_availability in ["out_of_stock", "discontinued"] do + ActivityLog.log_event("sync.variant_unavailable", + "#{product.title} (#{variant.title}): now #{new_availability}", + level: "warning", + payload: %{variant_id: variant.id, product_id: variant.product_id} + ) + end + end +end +``` + +**Cart impact:** +- On cart load: check all variants still available +- If out of stock: show warning "This size/colour is currently out of stock" +- If discontinued: show warning "This option is no longer available" +- Prevent checkout if cart contains unavailable variants (or offer to remove them) + +**Product page:** +- Out of stock variants: show crossed out or greyed, not selectable +- Discontinued variants: hide entirely from selector +- If all variants unavailable: show "Currently unavailable" on product + +--- + +## 3. Price change alerts + +Log a warning when a product's cost basis changes significantly (>5% or configurable threshold). Helps shop owner know when their margins might be affected. + +**Implementation:** +```elixir +def check_price_change(product, new_cost) do + old_cost = product.base_cost + return if old_cost == nil or old_cost == 0 + + change_pct = abs((new_cost - old_cost) / old_cost * 100) + + if change_pct > 5 do + ActivityLog.log_event("sync.price_change", + "#{product.title}: cost changed from £#{old_cost} to £#{new_cost} (#{round(change_pct)}%)", + level: if(change_pct > 20, do: "warning", else: "info"), + payload: %{product_id: product.id, old_cost: old_cost, new_cost: new_cost, change_pct: change_pct} + ) + end +end +``` + +--- + +## Files to Modify + +| File | Changes | +|------|---------| +| `lib/berrypod/products/product.ex` | Add `status` field if not present | +| `lib/berrypod/products/variant.ex` | Add `availability` field | +| `lib/berrypod/providers/printify.ex` | Add diff logic, price change check, soft-delete handling, variant availability sync | +| `lib/berrypod/providers/printful.ex` | Same as above | +| `lib/berrypod/products.ex` | Query helpers for discontinued products/variants | +| `lib/berrypod_web/live/shop/pages/product_page.ex` | Handle discontinued status, variant availability display | +| `lib/berrypod_web/components/shop_components/cart.ex` | Warn on discontinued/unavailable items | +| `lib/berrypod_web/components/shop_components/product.ex` | Grey out/hide unavailable variants in selector | + +--- + +## Tasks + +| # | Task | Est | +|---|------|-----| +| 1 | Add `status` field to products, `availability` field to variants | 30m | +| 2 | Product discontinued handling in sync (soft-delete) | 45m | +| 3 | Variant availability sync (out of stock / discontinued) | 45m | +| 4 | Detailed sync audit logging (field-level diff) | 1h | +| 5 | Price change alerts with threshold | 30m | +| 6 | Product page UI: discontinued products, variant availability display | 1h | +| 7 | Cart UI: warn on discontinued/unavailable items, prevent checkout | 1h | +| **Total** | | **5.5h** | + +--- + +## Verification + +1. Manually trigger a sync +2. Check activity log shows field-level changes +3. Remove a product from provider (or mock it) — verify soft-delete +4. Change a product's cost — verify price change alert appears +5. Try to add discontinued product to cart — verify warning +6. Mark a variant as out of stock — verify greyed out on product page +7. Mark a variant as discontinued — verify hidden from selector +8. Add item to cart, then mark its variant unavailable — verify cart warning +9. Try to checkout with unavailable variant — verify blocked or prompted to remove diff --git a/lib/berrypod/cart.ex b/lib/berrypod/cart.ex index 76a5217..62c1c2c 100644 --- a/lib/berrypod/cart.ex +++ b/lib/berrypod/cart.ex @@ -133,7 +133,8 @@ defmodule Berrypod.Cart do price: variant.price, quantity: quantity, image: variant_image_url(variant.product), - is_available: variant.is_available + is_available: variant.is_available, + product_discontinued: variant.product.status == "discontinued" } end end) diff --git a/lib/berrypod/products.ex b/lib/berrypod/products.ex index b8fea26..2497094 100644 --- a/lib/berrypod/products.ex +++ b/lib/berrypod/products.ex @@ -173,6 +173,19 @@ defmodule Berrypod.Products do |> Repo.one() end + @doc """ + Returns active (non-discontinued) products for a provider connection. + + Used by product sync to detect products that are no longer returned by the provider. + """ + def list_active_products_for_connection(connection_id) do + from(p in Product, + where: p.provider_connection_id == ^connection_id and p.status != "discontinued", + select: [:id, :provider_product_id, :title, :status] + ) + |> Repo.all() + end + @doc """ Enqueues a product sync job for the given provider connection. Returns `{:ok, job}` or `{:error, changeset}`. @@ -615,6 +628,9 @@ defmodule Berrypod.Products do product -> old_slug = product.slug + # Compute field-level diff before update + changes = compute_product_diff(product, attrs) + case update_product(product, attrs) do {:ok, updated} -> if old_slug != updated.slug do @@ -625,6 +641,11 @@ defmodule Berrypod.Products do }) end + # Log field-level changes + if changes != %{} do + log_product_updated(updated, changes) + end + {:ok, updated, :updated} error -> @@ -633,6 +654,44 @@ defmodule Berrypod.Products do end end + # Compute diff between existing product and new attributes + defp compute_product_diff(product, attrs) do + fields = [:title, :description, :category] + + Enum.reduce(fields, %{}, fn field, acc -> + old_val = Map.get(product, field) + new_val = attrs[field] || attrs[to_string(field)] + + if old_val != new_val and not is_nil(new_val) do + Map.put(acc, field, {old_val, new_val}) + else + acc + end + end) + end + + defp log_product_updated(product, changes) do + changed_fields = + changes + |> Map.keys() + |> Enum.map(&to_string/1) + |> Enum.join(", ") + + Berrypod.ActivityLog.log_event( + "sync.product_updated", + "#{product.title}: #{changed_fields} changed", + payload: %{product_id: product.id, changes: changes} + ) + end + + defp log_product_created(product) do + Berrypod.ActivityLog.log_event( + "sync.product_added", + "New product: #{product.title}", + payload: %{product_id: product.id, title: product.title} + ) + end + # If product exists with same slug, update it (including new provider_product_id) # Otherwise insert new product defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do @@ -679,6 +738,7 @@ defmodule Berrypod.Products do defp do_insert_product(attrs) do case create_product(attrs) do {:ok, product} -> + log_product_created(product) {:ok, product, :created} {:error, %Ecto.Changeset{errors: errors} = changeset} -> @@ -987,7 +1047,7 @@ defmodule Berrypod.Products do |> Repo.delete_all() end - # Upsert incoming variants + # Upsert incoming variants, tracking availability changes Enum.map(variants, fn variant_data -> provider_variant_id = variant_data[:provider_variant_id] || variant_data["provider_variant_id"] @@ -999,11 +1059,96 @@ defmodule Berrypod.Products do create_product_variant(attrs) existing -> - update_product_variant(existing, attrs) + # Check for availability changes before update + old_availability = ProductVariant.availability(existing) + + new_availability = + cond do + attrs[:is_available] == true and attrs[:is_enabled] == true -> :available + attrs[:is_available] == false and attrs[:is_enabled] == true -> :out_of_stock + attrs[:is_enabled] == false -> :discontinued + true -> :available + end + + # Check for cost changes before update + old_cost = existing.cost + new_cost = attrs[:cost] + + result = update_product_variant(existing, attrs) + + # Log if availability changed to a worse state + if old_availability == :available and new_availability != :available do + log_variant_availability_change(existing, product_id, new_availability) + end + + # Log significant cost changes (>5%) + if old_cost && new_cost && old_cost > 0 do + check_cost_change(existing, product_id, old_cost, new_cost) + end + + result end end) end + # Cost change threshold (5%) + @cost_change_threshold 5 + + defp check_cost_change(variant, product_id, old_cost, new_cost) do + change_pct = abs((new_cost - old_cost) / old_cost * 100) + + if change_pct > @cost_change_threshold do + # Load the product title for the log message + product = Repo.get(Product, product_id) + product_title = if product, do: product.title, else: "Unknown product" + + level = if change_pct > 20, do: "warning", else: "info" + + # Format costs as currency (they're stored in pence) + old_formatted = format_cost(old_cost) + new_formatted = format_cost(new_cost) + + Berrypod.ActivityLog.log_event( + "sync.price_change", + "#{product_title} (#{variant.title}): cost #{old_formatted} → #{new_formatted} (#{round(change_pct)}%)", + level: level, + payload: %{ + variant_id: variant.id, + product_id: product_id, + variant_title: variant.title, + old_cost: old_cost, + new_cost: new_cost, + change_pct: Float.round(change_pct, 1) + } + ) + end + end + + defp format_cost(cost_in_pence) do + pounds = cost_in_pence / 100 + "£#{:erlang.float_to_binary(pounds, decimals: 2)}" + end + + defp log_variant_availability_change(variant, product_id, new_availability) do + # Load the product title for the log message + product = Repo.get(Product, product_id) + product_title = if product, do: product.title, else: "Unknown product" + + status_text = if new_availability == :out_of_stock, do: "out of stock", else: "discontinued" + + Berrypod.ActivityLog.log_event( + "sync.variant_unavailable", + "#{product_title} (#{variant.title}): now #{status_text}", + level: "warning", + payload: %{ + variant_id: variant.id, + product_id: product_id, + variant_title: variant.title, + new_availability: new_availability + } + ) + end + # ============================================================================= # Private Helpers # ============================================================================= diff --git a/lib/berrypod/products/product.ex b/lib/berrypod/products/product.ex index 4cdb7df..fc92008 100644 --- a/lib/berrypod/products/product.ex +++ b/lib/berrypod/products/product.ex @@ -12,7 +12,7 @@ defmodule Berrypod.Products.Product do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @statuses ~w(active draft archived) + @statuses ~w(active draft archived discontinued) schema "products" do field :provider_product_id, :string @@ -43,6 +43,21 @@ defmodule Berrypod.Products.Product do """ def statuses, do: @statuses + @doc """ + Returns true if the product is discontinued (no longer available from provider). + """ + def discontinued?(%__MODULE__{status: "discontinued"}), do: true + def discontinued?(_), do: false + + @doc """ + Returns true if the product can be purchased. + """ + def purchasable?(%__MODULE__{status: status, visible: visible}) do + status == "active" and visible == true + end + + def purchasable?(_), do: false + @doc """ Changeset for creating or updating a product. """ diff --git a/lib/berrypod/products/product_variant.ex b/lib/berrypod/products/product_variant.ex index e337066..d17f524 100644 --- a/lib/berrypod/products/product_variant.ex +++ b/lib/berrypod/products/product_variant.ex @@ -96,4 +96,25 @@ defmodule Berrypod.Products.ProductVariant do end def options_title(_), do: nil + + @doc """ + Returns the availability status of the variant. + + - `:available` — in stock and enabled + - `:out_of_stock` — not in stock but still enabled (may come back) + - `:discontinued` — disabled by provider (won't come back) + """ + def availability(%__MODULE__{is_available: true, is_enabled: true}), do: :available + def availability(%__MODULE__{is_available: false, is_enabled: true}), do: :out_of_stock + def availability(%__MODULE__{is_enabled: false}), do: :discontinued + def availability(_), do: :available + + @doc """ + Returns true if the variant can be purchased. + """ + def purchasable?(%__MODULE__{} = variant) do + availability(variant) == :available + end + + def purchasable?(_), do: false end diff --git a/lib/berrypod/sync/product_sync_worker.ex b/lib/berrypod/sync/product_sync_worker.ex index bda8bc6..7be3f11 100644 --- a/lib/berrypod/sync/product_sync_worker.ex +++ b/lib/berrypod/sync/product_sync_worker.ex @@ -92,6 +92,9 @@ defmodule Berrypod.Sync.ProductSyncWorker do results = sync_all_products(conn, products) + # Mark products not returned by provider as discontinued + discontinued = mark_discontinued_products(conn, products) + created = Enum.count(results, &match?({:ok, _, :created}, &1)) updated = Enum.count(results, &match?({:ok, _, :updated}, &1)) unchanged = Enum.count(results, &match?({:ok, _, :unchanged}, &1)) @@ -99,17 +102,20 @@ defmodule Berrypod.Sync.ProductSyncWorker do Logger.info( "Product sync complete for #{conn.provider_type}: " <> - "#{created} created, #{updated} updated, #{unchanged} unchanged, #{errors} errors" + "#{created} created, #{updated} updated, #{unchanged} unchanged, " <> + "#{discontinued} discontinued, #{errors} errors" ) ActivityLog.log_event( "sync.completed", - "Product sync complete — #{created + updated + unchanged} products, #{created} created, #{updated} updated", + "Product sync complete — #{created + updated + unchanged} products, #{created} created, #{updated} updated" <> + if(discontinued > 0, do: ", #{discontinued} discontinued", else: ""), payload: %{ provider_type: conn.provider_type, created: created, updated: updated, unchanged: unchanged, + discontinued: discontinued, errors: errors } ) @@ -168,6 +174,30 @@ defmodule Berrypod.Sync.ProductSyncWorker do end) end + # Mark products that exist locally but weren't returned by the provider as discontinued. + # Returns the count of products that were discontinued. + defp mark_discontinued_products(conn, products) do + # Get provider_product_ids from the fetched products + fetched_ids = MapSet.new(products, & &1[:provider_product_id]) + + # Find local active products not in the fetched list + Products.list_active_products_for_connection(conn.id) + |> Enum.reject(fn product -> MapSet.member?(fetched_ids, product.provider_product_id) end) + |> Enum.map(fn product -> + Logger.info("Marking product #{product.title} as discontinued (no longer in provider)") + + ActivityLog.log_event( + "sync.product_discontinued", + "#{product.title} no longer available from provider", + level: "warning", + payload: %{product_id: product.id, title: product.title} + ) + + Products.update_product(product, %{status: "discontinued"}) + end) + |> length() + end + defp sync_single_product(conn, product_data) do case sync_product(conn, product_data) do {:ok, product, status} -> diff --git a/lib/berrypod_web/components/shop_components/cart.ex b/lib/berrypod_web/components/shop_components/cart.ex index 9f47ecb..37265e5 100644 --- a/lib/berrypod_web/components/shop_components/cart.ex +++ b/lib/berrypod_web/components/shop_components/cart.ex @@ -45,10 +45,18 @@ defmodule BerrypodWeb.ShopComponents.Cart do attr :stripe_connected, :boolean, default: true def cart_drawer(assigns) do + # Check if any cart items are unavailable (out of stock or discontinued) + has_unavailable_items = + Enum.any?(assigns.cart_items, fn item -> + Map.get(item, :product_discontinued) == true or Map.get(item, :is_available) == false + end) + assigns = - assign_new(assigns, :display_total, fn -> + assigns + |> assign_new(:display_total, fn -> assigns.total || assigns.subtotal || "£0.00" end) + |> assign(:has_unavailable_items, has_unavailable_items) ~H""" <%!-- Screen reader announcements for cart changes --%> @@ -142,6 +150,13 @@ defmodule BerrypodWeb.ShopComponents.Cart do Checkout
Checkout isn't available yet.
+ <% @has_unavailable_items -> %> + ++ Remove unavailable items to checkout. +
<% true -> %>