PhoenixKitEcommerce.Shopify.SyncScope (PhoenixKitEcommerce v0.5.11)

Copy Markdown View Source

What "in scope" means for a Shopify → catalogue sync, when the connected store carries far more products than this shop wants in its catalogue (e.g. a store with 2750 Shopify products syncing only ~665 of them, selected by a catalog-3d tag).

Persisted as one phoenix_kit_shop_config row under "shopify_sync_scope" (PhoenixKitEcommerce.get_config/1/default_config_value/1, mirroring "shopify_collections_filter"'s own key/default pattern), shape:

%{"mode" => "all" | "filtered", "tags" => [String.t()],
  "product_types" => [String.t()]}

"all" — the default, and what an operator who never configured this gets — means every Shopify product is in scope; "filtered" narrows to products carrying at least one of "tags" (when non-empty) AND whose product_type is in "product_types" (when non-empty). A "filtered" scope with both lists empty behaves exactly like "all" — see in_scope?/2.

This module is deliberately generic (no hardcoded tag or product type): a different shop binds it to a different tag, a product type, or leaves it at "all" — see PhoenixKitEcommerce.Workers.ShopifyMediaSyncWorker and PhoenixKitEcommerce.Shopify.Sync.check/2, the two callers that consult it to decide what an UNMATCHED Shopify product means: in scope, it's missing from the catalogue and worth flagging; out of scope, its absence is by design and must never be reported as an error or offered for import. Neither caller ever scopes a product the catalogue ALREADY has — see each one's own moduledoc for why.

Summary

Functions

The scope that means "everything" — get/0's default.

Whether scope is a "filtered" scope (as opposed to "all").

Reads the current scope from phoenix_kit_shop_config, normalized.

Whether a Shopify product (raw Admin API map) falls within scope.

Splits products into {in_scope, out_of_scope} per in_scope?/2, preserving each side's relative order.

Same as partition/2, but for a list of ITEMS that each wrap a raw Shopify product rather than being one — extract_fun pulls the product map out of each item for the in_scope?/2 check, while both output lists still carry the original items, not the extracted products. Shopify.Sync.check/2's own :new_products uses this to partition ProductDiff.Change structs by their .shopify_product without a second, independent Enum.split_with/2.

Validates and persists a scope, insert-or-update on the single "shopify_sync_scope" row (same pattern as PhoenixKitEcommerce.update_storefront_filters/1).

Types

mode()

@type mode() :: :all | :filtered

t()

@type t() :: %{mode: mode(), tags: [String.t()], product_types: [String.t()]}

Functions

all()

@spec all() :: t()

The scope that means "everything" — get/0's default.

filtered?(arg1)

@spec filtered?(t()) :: boolean()

Whether scope is a "filtered" scope (as opposed to "all").

get()

@spec get() :: t()

Reads the current scope from phoenix_kit_shop_config, normalized.

Never raises on a missing or malformed stored value — anything that isn't a recognizable "filtered" scope reads as all(), the same fail-open posture CollectionSync's own filter default takes.

in_scope?(product, map)

@spec in_scope?(map(), t()) :: boolean()

Whether a Shopify product (raw Admin API map) falls within scope.

mode: :all (or a :filtered scope with both lists empty — see the moduledoc) always returns true. Otherwise: true when (tags is empty OR the product carries at least one of them) AND (product_types is empty OR the product's "product_type" is one of them) — both sides must pass, so a scope configured with only tags ignores product type entirely, and vice versa.

Shopify's "tags" field is a single comma-separated string on the Admin API (occasionally already a list, e.g. from a test fixture or a future API version); either shape is accepted. Comparison is case-insensitive and trims whitespace on both sides, matching how an operator is likely to have typed the scope's own tags.

partition(products, scope)

@spec partition([map()], t()) :: {[map()], [map()]}

Splits products into {in_scope, out_of_scope} per in_scope?/2, preserving each side's relative order.

partition(items, scope, extract_fun)

@spec partition([term()], t(), (term() -> map())) :: {[term()], [term()]}

Same as partition/2, but for a list of ITEMS that each wrap a raw Shopify product rather than being one — extract_fun pulls the product map out of each item for the in_scope?/2 check, while both output lists still carry the original items, not the extracted products. Shopify.Sync.check/2's own :new_products uses this to partition ProductDiff.Change structs by their .shopify_product without a second, independent Enum.split_with/2.

put(attrs)

@spec put(map()) :: {:ok, t()} | {:error, :invalid_mode | Ecto.Changeset.t()}

Validates and persists a scope, insert-or-update on the single "shopify_sync_scope" row (same pattern as PhoenixKitEcommerce.update_storefront_filters/1).

Accepts the same shape get/0 returns (string OR atom keys, string OR atom mode), normalizes it (trims tags/product types, drops blanks, dedupes, downcases nothing — Shopify tags/product types are compared case-insensitively at match time in in_scope?/2, not folded here so the stored value still reads back as the operator typed it), and refuses anything whose mode isn't "all"/"filtered" (or :all/ :filtered) with {:error, :invalid_mode}.