PhoenixKitEcommerce.Catalogue.Writer (PhoenixKitEcommerce v0.5.0)

Copy Markdown View Source

Writes Shopify sync changes into phoenix_kit_catalogue items — the write side of Block 3's "sync 6a" (docs/superpowers/specs/2026-09-05- catalogue-as-shop-product-list-design.md §5 Блок 3) and Block 7's 6b (same doc, same §, "Блок 7"), active only when ProductSource. current/0 is Catalogue. update_from_shopify/3/create_from_shopify/2 are called by PhoenixKitEcommerce.Shopify.Sync; sync_variants/2 (and the images/collections writers Block 7 adds alongside it) is called directly by the sync worker instead — nothing here touches phoenix_kit_shop_products (the legacy writer, Shop.update_product/2, stays the write path for the legacy source).

Every function is a thin translation from Shopify's field names to PhoenixKitCatalogue.Schemas.Item columns / data["ecommerce"] (PhoenixKitEcommerce.Catalogue.ItemCommerce) — no diffing (that's ProductDiff's job) and no network access.

title/body_html land on the item's own :name/:description columns when writing in the item's PRIMARY language, else as a multilang override (data[lang]["_name"]/["_description"]) — same primary-vs-override split PhoenixKitCatalogue.Catalogue.Translations reads. description (the ecommerce short summary, PhoenixKitEcommerce.Product.description) always writes data[lang]["_summary"]: unlike name/body_html it has no primary-column counterpart at all, in either language.

Summary

Functions

Creates a catalogue item from a Shopify Admin API product payload for a handle with no local match (ProductDiff.new_product_changes/3).

Downloads shopify_product's "images" into Storage and attaches them to item in Shopify's own position order (ascending; the payload's own array order is NOT trusted), featured = the position-1 image.

Turns shopify_product's options/variants (PhoenixKitEcommerce.Shopify.VariantMapper.build/1) into catalogue attribute-set attachments on item: one set per real Shopify option (found by blueprint name "catalogue_set_" <> slug, created kind: "fixed" when missing), values resolved to slugs via ValueResolver. resolve_many/3 (unknown labels become draft values), attached in Shopify's option order and selected in label order, with a slug-keyed price-modifier map written to data["ecommerce"] ["price_modifiers"][set_slug].

Applies change_fields — a plain %{field_atom => incoming_value} map, built by Shopify.Sync.apply_change/2 from a ProductDiff.Change's changes (unwrapped of its %{current:, incoming:} shape) — to item, writing localized fields into base_locale (the SAME locale the change was diffed against — see ProductDiff.Change's moduledoc for why that matters).

Functions

create_from_shopify(shopify_product, base_locale)

@spec create_from_shopify(map(), String.t()) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, Ecto.Changeset.t() | [{atom(), String.t()}] | :catalogue_not_found}

Creates a catalogue item from a Shopify Admin API product payload for a handle with no local match (ProductDiff.new_product_changes/3).

name/description are written as the item's own columns (a brand new item has no other language yet, so base_locale — whatever locale the sync ran in — IS this item's primary language); slug[base_locale] comes from Slugs.from_title/3, retried with a -2/-3 numeric suffix on a slug collision (3 attempts total, same shape the data migration's own slug retry uses); base_price from the cheapest variant; markup_percentage 0 (Shopify price is the single source of truth — see the design spec's pricing principle); unit "piece"; status "active"; category_uuid nil (uncategorized, same as a legacy-sync-created product used to be — sorting into a category is a manual follow-up either way); data["ecommerce"] carries shopify: %{"handle" => ..., "product_id" => ...} (product_id stringified, same as update_from_shopify/3's own backfill — every reader, CollectionSync and the Task 5 worker's item index included, matches it as a string) and shop_status derived from the Shopify product's own status.

sync_images(item, shopify_product, opts \\ [])

@spec sync_images(PhoenixKitCatalogue.Schemas.Item.t(), map(), keyword()) ::
  {:ok,
   %{
     downloaded: non_neg_integer(),
     reused: non_neg_integer(),
     attached: non_neg_integer(),
     errors: [{String.t(), term()}]
   }}
  | {:error, :catalogue_source_inactive | term()}

Downloads shopify_product's "images" into Storage and attaches them to item in Shopify's own position order (ascending; the payload's own array order is NOT trusted), featured = the position-1 image.

Resolves each Shopify image in this order: (a) an id already in data ["ecommerce"]["shopify"]["image_ids"] (%{"<shopify image id>" => file_uuid}) reuses its file uuid; (b) failing that, a Storage file already linked to item — one of its data["media_order"]/ featured_image_uuid files, or any file living in (or folder-linked into) its data["files_folder_uuid"] — whose metadata["source_url"] matches the Shopify image's src once both are stripped of their ?v=-style query string reuses that file instead of downloading a second copy of it (this is what lets a rerun against a catalogue item migrated with plain Storage images, never tagged with a Shopify image id, converge without duplicating every file); (c) otherwise downloads via opts[:downloader] (default &ImageDownloader.download_and_store/ 3, (url, user_uuid, opts) -> {:ok, file_uuid} | {:error, reason}).

opts[:user_uuid] is the Storage file owner for anything downloaded; when omitted it falls back to PhoenixKit.Users.Auth. get_first_admin_uuid/0 so an unattended sync run never fails Storage's user_uuid can't be blank validation for lack of an explicit actor.

A download failure never corrupts a binding that does not belong to it: an image id already synced before (image_ids) or matched to a previously-linked file by source_url is resolved WITHOUT ever attempting a download (see (a)/(b) above), so an existing binding can never be lost to a transient failure. An image with neither — a genuinely new Shopify image whose download fails — is skipped: not attached, not recorded in image_ids, so a later run retries it. It is never bound to some other image's file merely because it landed at the same list position; still listed in :errors so the failure stays visible.

A no-op — {:error, :catalogue_source_inactive} — when ProductSource.current/0 isn't Catalogue (Global Constraints: every new Block 7 writer is legacy-source-safe on its own).

Idempotent: a second run against the same payload resolves every image id to its already-known file uuid (downloaded: 0) and re-attaches the same order. image_ids is rewritten fresh from the current sync each run (same "derived fresh" idiom sync_variants/2 uses for price_modifiers/set_slugs) — an id Shopify no longer lists is dropped from it, though the file itself is left attached (no deletions in this block).

sync_variants(item, shopify_product)

@spec sync_variants(PhoenixKitCatalogue.Schemas.Item.t(), map()) ::
  {:ok, %{sets: non_neg_integer(), values_created: non_neg_integer()}}
  | {:error, :catalogue_source_inactive | term()}

Turns shopify_product's options/variants (PhoenixKitEcommerce.Shopify.VariantMapper.build/1) into catalogue attribute-set attachments on item: one set per real Shopify option (found by blueprint name "catalogue_set_" <> slug, created kind: "fixed" when missing), values resolved to slugs via ValueResolver. resolve_many/3 (unknown labels become draft values), attached in Shopify's option order and selected in label order, with a slug-keyed price-modifier map written to data["ecommerce"] ["price_modifiers"][set_slug].

A no-op — {:error, :catalogue_source_inactive} — when ProductSource.current/0 isn't Catalogue (Global Constraints: every new Block 7 writer is legacy-source-safe on its own, not only via whatever caller happens to gate it).

Idempotent: a second call against the same shopify_product resolves every label to its already-created slug (values_created: 0), leaves already-selected/attached sets untouched (no write, no activity row — AttributeSets.attach_set/3 and set_attachment_selection/4 are both no-ops on an unchanged state), and rewrites the same price_modifiers/ set_slugs. Any set previously written by a Shopify sync (tracked in data["ecommerce"]["shopify"]["set_slugs"]) that this product no longer has options for is detached and dropped from both that list and price_modifiers.

update_from_shopify(item, change_fields, base_locale)

@spec update_from_shopify(PhoenixKitCatalogue.Schemas.Item.t(), map(), String.t()) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, Ecto.Changeset.t() | [{atom(), String.t()}]}

Applies change_fields — a plain %{field_atom => incoming_value} map, built by Shopify.Sync.apply_change/2 from a ProductDiff.Change's changes (unwrapped of its %{current:, incoming:} shape) — to item, writing localized fields into base_locale (the SAME locale the change was diffed against — see ProductDiff.Change's moduledoc for why that matters).

Recognized keys: :title, :body_html, :description, :vendor, :tags, :status (mapped to data["ecommerce"]["shop_status"]), :price (→ base_price), :compare_at_price. Any other key is ignored — this mirrors ProductDiff.comparable_fields/0's set, but doesn't hard-code it, so a caller that already filtered change_fields (e.g. to a single field an operator picked) never has to know that.

:handle and :product_id are the exception: not part of ProductDiff.comparable_fields/0, they are merged into data["ecommerce"]["shopify"] (product_id stringified) whenever present, alongside whatever Writer or a later Shopify sync already wrote there (image_ids, set_slugs, collection_id) — never replacing that sub-map wholesale. Shopify.Sync.apply_change/2 sets both on every applied Change, backfilling identity even when the caller only asked for a subset of the diffed fields.