PhoenixKitCatalogue.Catalogue (PhoenixKitCatalogue v0.2.0)

Copy Markdown View Source

Context module for managing catalogues, manufacturers, suppliers, categories, and items.

Soft-Delete System

Catalogues, categories, and items support soft-delete via a status field set to "deleted". Manufacturers and suppliers use hard-delete only (they are reference data).

Cascade behaviour

Downward cascade on trash/permanently_delete:

  • Trashing a catalogue → trashes all its categories and their items
  • Trashing a category → trashes all its items
  • Permanently deleting follows the same cascade but removes from DB

Upward cascade on restore:

  • Restoring an item → restores its parent category if deleted
  • Restoring a category → restores its parent catalogue if deleted, plus all items

All cascading operations are wrapped in database transactions.

Usage from IEx

alias PhoenixKitCatalogue.Catalogue

# Create a full hierarchy
{:ok, cat} = Catalogue.create_catalogue(%{name: "Kitchen"})
{:ok, category} = Catalogue.create_category(%{name: "Frames", catalogue_uuid: cat.uuid})
{:ok, item} = Catalogue.create_item(%{name: "Oak Panel", category_uuid: category.uuid, base_price: 25.50})

# Soft-delete and restore
{:ok, _} = Catalogue.trash_catalogue(cat)   # cascades to category + item
{:ok, _} = Catalogue.restore_catalogue(cat)  # cascades back

# Move operations
{:ok, _} = Catalogue.move_category_to_catalogue(category, other_catalogue_uuid)
{:ok, _} = Catalogue.move_item_to_category(item, other_category_uuid)

Smart catalogues

For an end-to-end walkthrough of integrating smart catalogues (kind: "smart" items priced as functions of other catalogues), see the Smart Catalogues guide.

Summary

Functions

Bulk-moves items to a target category within a single catalogue.

Bulk hard-deletes items by UUID. Use with care — no soft-delete cycle. Logs a single item.bulk_permanently_deleted activity row when count > 0.

Bulk restores items by UUID. Skips items whose parent catalogue is deleted (returns only the count of items actually flipped to active). Items with deleted parent categories are uncategorized on restore — same rule as restore_item/2.

Bulk soft-deletes categories by UUID with a uniform item disposition (cascade / uncategorize / move_to). Each category goes through the same logic as trash_category/2. Returns {:ok, %{categories: count, items_handled: count}} or surfaces the first error.

Bulk soft-deletes items by UUID. Empty list is a no-op. Logs a single item.bulk_trashed activity row when count > 0.

One-shot helper for lazy-loading a catalogue's category tree. Returns category metadata plus per-category and uncategorized item counts in two queries instead of three.

Returns a changeset for tracking catalogue changes.

Returns a changeset for tracking category changes.

Returns a changeset for tracking item changes.

Creates a category within a catalogue.

Hard-deletes a catalogue. Prefer trash_catalogue/1 for soft-delete.

Hard-deletes a category. Prefer trash_category/1 for soft-delete.

Hard-deletes an item. Prefer trash_item/1 for soft-delete.

Returns the count of soft-deleted catalogues.

Fetches a catalogue by UUID without preloading categories or items. Raises Ecto.NoResultsError if not found. Prefer this over get_catalogue!/2 in read paths that don't need the nested preloads (e.g. the infinite-scroll detail view, which pages categories and items separately).

Fetches a catalogue by UUID without preloads. Returns nil if not found.

Fetches a catalogue by UUID with preloaded categories and items. Raises Ecto.NoResultsError if not found.

Fetches a category by UUID. Returns nil if not found.

Fetches a category by UUID. Raises Ecto.NoResultsError if not found.

Fetches an item by UUID. Returns nil if not found.

Fetches an item by UUID with preloaded :catalogue, :category, and :manufacturer. Raises Ecto.NoResultsError if not found.

Counts items in a single category (ignoring its catalogue scope).

Returns a map of %{category_uuid => item_count} for every category in a catalogue in a single grouped query. Used by the infinite-scroll detail view so each category card can show its total count without a separate per-card round trip.

Returns the full pricing breakdown for an item within its catalogue.

Lists all non-deleted categories across all non-deleted catalogues, with breadcrumb-style names prefixed by their catalogue and every ancestor category (e.g. "Kitchen / Cabinets / Frames"). Useful for item move dropdowns where the user needs to distinguish same-named leaves under different parents.

Lists catalogues, ordered by name. Excludes deleted by default.

Lists catalogues whose name starts with prefix, case-insensitive.

Lists non-deleted categories for a catalogue, ordered by position then name.

Lists categories for a catalogue without preloading items, ordered by position then name. Used by the infinite-scroll detail view to walk categories in display order without fetching potentially thousands of items up front.

Returns the list of ancestor categories from root down to (but not including) category_uuid. Empty when the category is a root. Useful for breadcrumbs.

Returns the categories in a catalogue paired with their tree depth, in depth-first display order (position, then name, recursing into children). Each entry is {category, depth} where depth 0 means a root. Used to render flat parent-pickers and indented listings.

Lists soft-deleted items in a catalogue as a flat list, ordered by deletion date (most-recently-deleted first). updated_at is the deletion-time proxy — flipping status to "deleted" always bumps it. Used by the Items tab Deleted view, which surfaces a recency- ordered audit list rather than category-grouped cards.

Lists all non-deleted items across all catalogues, ordered by name.

Bulk-fetches items by a list of UUIDs. Excludes soft-deleted items. Result order matches the input UUID order; missing UUIDs are dropped (no nil placeholders, no error). Duplicate input UUIDs collapse to a single result.

Lists non-deleted items for a catalogue, ordered by category position then item name. Includes uncategorized items (those with no category) at the end.

Lists non-deleted items for a category, ordered by position then name.

Lists a page of items for a single category, ordered by name.

Returns same-catalogue active categories that can receive items from a category about to be deleted (the category itself and its V103 descendants are excluded). Used by the admin "delete category" modal to populate the move-target dropdown.

Lists uncategorized items (no category assigned) for a specific catalogue.

Lists a page of uncategorized items for a catalogue, ordered by name.

Moves a category — along with its entire subtree and every item inside — to a different catalogue.

Reparents a category within the same catalogue, placing it under new_parent_uuid (or promoting it to a root with nil).

Moves an item to a different catalogue, clearing its category.

Moves an item to a different category.

Returns the next available position for a new catalogue — one past the current max, falling back to 1 on an empty table.

Returns the next available position for a new category among its siblings. Position is scoped to (catalogue_uuid, parent_uuid) — the set of categories sharing the same parent within a catalogue — since V103's nested-category tree makes a single catalogue-wide ordering ambiguous.

Returns the next available position for a new item within a scope.

Permanently deletes a catalogue and all its contents from the database.

Permanently deletes a category and its entire subtree (all descendant categories + every item in any of them) from the database.

Permanently deletes an item from the database. This cannot be undone.

Re-indexes the supplied list of catalogue UUIDs into positions 1..N. Used by the catalogues index DnD handler.

Re-indexes a sibling group of categories from a list of UUIDs.

Re-indexes multiple sibling groups of categories in one outer transaction — the LV layer hits this when a single drop touches more than one parent group.

Re-indexes the items inside a (catalogue_uuid, category_uuid) bucket. Pass category_uuid: nil to reorder the uncategorized bucket. Behaves like reorder_categories/4: validates scope, runs two passes inside a transaction, logs an activity row.

Restores a soft-deleted catalogue by setting its status to "active".

Restores a soft-deleted category by flipping its status back to "active". No cascades — each entity owns its own status, so restore-as-undo doesn't ripple sideways.

Restores a soft-deleted item by setting its status to "active".

Atomically swaps the positions of two categories within a transaction.

Soft-deletes a catalogue by setting its status to "deleted".

Soft-deletes a category and its entire subtree by setting their status to "deleted".

Soft-deletes an item by setting its status to "deleted".

Bulk soft-deletes all non-deleted items in a category.

Counts non-deleted uncategorized items for a catalogue (items with category_uuid IS NULL). Used to decide whether the infinite-scroll detail view needs to show an "Uncategorized" card at all.

Updates a catalogue with the given attributes.

Updates a category with the given attributes.

Updates an item with the given attributes.

Functions

active_item_count_in_subtree(category_uuid)

See PhoenixKitCatalogue.Catalogue.Counts.active_item_count_in_subtree/1.

bulk_move_items_to_category(uuids, target_uuid, opts)

@spec bulk_move_items_to_category([Ecto.UUID.t()], Ecto.UUID.t() | nil, keyword()) ::
  {:ok, non_neg_integer()}
  | {:error, :category_not_found}
  | {:error, :wrong_catalogue_scope}
  | {:error, :missing_catalogue_scope}

Bulk-moves items to a target category within a single catalogue.

Required opts

  • :catalogue_uuid — the calling LV's catalogue scope. Every item in uuids MUST already belong to this catalogue, and target_uuid (when not nil) must live in this catalogue. The single-item DnD handler enforces the same scope; this guard makes the bulk path symmetric so a crafted client request can't silently flip an item's catalogue_uuid cross-catalogue.

Pass target_uuid: nil to uncategorize all items within their catalogue.

Returns {:ok, count}, {:error, :category_not_found} (target), {:error, :wrong_catalogue_scope} (target lives elsewhere or one or more items don't belong to :catalogue_uuid), or {:error, :missing_catalogue_scope} (caller forgot the required opt).

bulk_permanently_delete_items(uuids, opts)

@spec bulk_permanently_delete_items(
  [Ecto.UUID.t()],
  keyword()
) :: {non_neg_integer(), nil}

Bulk hard-deletes items by UUID. Use with care — no soft-delete cycle. Logs a single item.bulk_permanently_deleted activity row when count > 0.

bulk_restore_items(uuids, opts)

@spec bulk_restore_items(
  [Ecto.UUID.t()],
  keyword()
) :: {non_neg_integer(), nil}

Bulk restores items by UUID. Skips items whose parent catalogue is deleted (returns only the count of items actually flipped to active). Items with deleted parent categories are uncategorized on restore — same rule as restore_item/2.

Wrapped in repo().transaction/1 so the read-then-partition-then-write pipeline can't be interleaved with another connection flipping a parent's status mid-flight. Without that envelope a concurrent category trash/restore could push the partition off-by-one and either detach an item that should have stayed attached or vice versa.

bulk_trash_categories(uuids, disposition, opts)

@spec bulk_trash_categories(
  [Ecto.UUID.t()],
  :cascade | :uncategorize | {:move_to, Ecto.UUID.t()},
  keyword()
) ::
  {:ok, %{categories: non_neg_integer(), items_handled: non_neg_integer()}}
  | {:error, term()}

Bulk soft-deletes categories by UUID with a uniform item disposition (cascade / uncategorize / move_to). Each category goes through the same logic as trash_category/2. Returns {:ok, %{categories: count, items_handled: count}} or surfaces the first error.

bulk_trash_items(uuids, opts)

@spec bulk_trash_items(
  [Ecto.UUID.t()],
  keyword()
) :: {non_neg_integer(), nil}

Bulk soft-deletes items by UUID. Empty list is a no-op. Logs a single item.bulk_trashed activity row when count > 0.

catalogue_reference_count(catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Rules.catalogue_reference_count/1.

catalogue_rule_map(item_or_uuid)

See PhoenixKitCatalogue.Catalogue.Rules.catalogue_rule_map/1.

category_count_for_catalogue(catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Counts.category_count_for_catalogue/1.

category_counts_by_catalogue()

See PhoenixKitCatalogue.Catalogue.Counts.category_counts_by_catalogue/0.

category_summary_for_catalogue(catalogue_uuid, opts \\ [])

@spec category_summary_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: %{
  categories: [PhoenixKitCatalogue.Schemas.Category.t()],
  item_counts: %{required(Ecto.UUID.t()) => non_neg_integer()},
  uncategorized_count: non_neg_integer()
}

One-shot helper for lazy-loading a catalogue's category tree. Returns category metadata plus per-category and uncategorized item counts in two queries instead of three.

Combines the work of:

Categories are ordered the same way list_categories_metadata_for_catalogue/2 orders them. Empty categories don't appear in :item_counts (treat missing keys as 0).

Options

  • :mode:active (default, excludes deleted) or :deleted. Mode is applied uniformly to both the categories query and the item-count query.

change_catalogue(catalogue, attrs \\ %{})

Returns a changeset for tracking catalogue changes.

change_catalogue_rule(rule, attrs \\ %{})

See PhoenixKitCatalogue.Catalogue.Rules.change_catalogue_rule/2.

change_category(category, attrs \\ %{})

Returns a changeset for tracking category changes.

change_item(item, attrs \\ %{})

Returns a changeset for tracking item changes.

change_manufacturer(manufacturer, attrs \\ %{})

See PhoenixKitCatalogue.Catalogue.Manufacturers.change_manufacturer/2.

change_supplier(supplier, attrs \\ %{})

See PhoenixKitCatalogue.Catalogue.Suppliers.change_supplier/2.

count_pdfs(opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.count_pdfs/1.

count_search_items(query, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Search.count_search_items/2.

count_search_items_in_catalogue(catalogue_uuid, query)

See PhoenixKitCatalogue.Catalogue.Search.count_search_items_in_catalogue/2.

count_search_items_in_category(category_uuid, query)

See PhoenixKitCatalogue.Catalogue.Search.count_search_items_in_category/2.

create_catalogue(attrs, opts \\ [])

Creates a catalogue.

Required attributes

  • :name — catalogue name (1-255 chars)

Optional attributes

  • :description — text description
  • :status"active" (default), "archived", or "deleted"
  • :data — flexible JSON map

Examples

Catalogue.create_catalogue(%{name: "Kitchen Furniture"})

create_catalogue_rule(attrs, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Rules.create_catalogue_rule/2.

create_category(attrs, opts \\ [])

Creates a category within a catalogue.

Required attributes

  • :name — category name (1-255 chars)
  • :catalogue_uuid — the parent catalogue

Optional attributes

  • :description, :position (default 0), :status ("active" or "deleted")
  • :data — flexible JSON map

Examples

Catalogue.create_category(%{name: "Frames", catalogue_uuid: catalogue.uuid})

create_item(attrs, opts \\ [])

Creates an item.

Required attributes

  • :name — item name (1-255 chars)
  • :catalogue_uuid — the parent catalogue (required). Auto-derived from :category_uuid when omitted and a category is provided.

Optional attributes

  • :description — text description
  • :sku — stock keeping unit (unique, max 100 chars)
  • :base_price — decimal, must be >= 0 (cost/purchase price before markup)
  • :unit"piece" (default), "m2", or "running_meter"
  • :status"active" (default), "inactive", "discontinued", or "deleted"
  • :category_uuid — the parent category (optional — leave nil for uncategorized items)
  • :manufacturer_uuid — the manufacturer (optional)
  • :data — flexible JSON map

Examples

Catalogue.create_item(%{name: "Oak Panel 18mm", catalogue_uuid: cat.uuid, base_price: 25.50})
Catalogue.create_item(%{name: "Hinge", category_uuid: category.uuid, manufacturer_uuid: m.uuid})

create_manufacturer(attrs, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Manufacturers.create_manufacturer/2.

create_pdf_from_upload(tmp_path, original_filename, opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.create_pdf_from_upload/3.

create_supplier(attrs, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Suppliers.create_supplier/2.

delete_catalogue(catalogue, opts \\ [])

Hard-deletes a catalogue. Prefer trash_catalogue/1 for soft-delete.

delete_catalogue_rule(rule, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Rules.delete_catalogue_rule/2.

delete_category(category, opts \\ [])

@spec delete_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Category.t()} | {:error, term()}

Hard-deletes a category. Prefer trash_category/1 for soft-delete.

delete_item(item, opts \\ [])

@spec delete_item(
  PhoenixKitCatalogue.Schemas.Item.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Item.t()} | {:error, term()}

Hard-deletes an item. Prefer trash_item/1 for soft-delete.

delete_manufacturer(manufacturer, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Manufacturers.delete_manufacturer/2.

delete_supplier(supplier, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Suppliers.delete_supplier/2.

deleted_catalogue_count()

@spec deleted_catalogue_count() :: non_neg_integer()

Returns the count of soft-deleted catalogues.

deleted_category_count_for_catalogue(catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Counts.deleted_category_count_for_catalogue/1.

deleted_count_for_catalogue(catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Counts.deleted_count_for_catalogue/1.

deleted_item_count_for_catalogue(catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Counts.deleted_item_count_for_catalogue/1.

evaluate_smart_rules(entries, opts \\ [])

See PhoenixKitCatalogue.Catalogue.SmartPricing.evaluate_smart_rules/2.

fetch_catalogue!(uuid)

@spec fetch_catalogue!(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Catalogue.t()

Fetches a catalogue by UUID without preloading categories or items. Raises Ecto.NoResultsError if not found. Prefer this over get_catalogue!/2 in read paths that don't need the nested preloads (e.g. the infinite-scroll detail view, which pages categories and items separately).

get_catalogue(uuid)

@spec get_catalogue(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Catalogue.t() | nil

Fetches a catalogue by UUID without preloads. Returns nil if not found.

get_catalogue!(uuid, opts \\ [])

@spec get_catalogue!(
  Ecto.UUID.t(),
  keyword()
) :: PhoenixKitCatalogue.Schemas.Catalogue.t()

Fetches a catalogue by UUID with preloaded categories and items. Raises Ecto.NoResultsError if not found.

Options

  • :mode:active (default) or :deleted
    • :active — preloads non-deleted categories with non-deleted items
    • :deleted — preloads all categories with only deleted items (so you can see which categories contain trashed items)

Examples

Catalogue.get_catalogue!(uuid)                  # active view
Catalogue.get_catalogue!(uuid, mode: :deleted)  # deleted view

get_catalogue_rule(item_uuid, referenced_catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Rules.get_catalogue_rule/2.

get_category(uuid)

@spec get_category(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Category.t() | nil

Fetches a category by UUID. Returns nil if not found.

get_category!(uuid)

Fetches a category by UUID. Raises Ecto.NoResultsError if not found.

get_item(uuid, opts \\ [])

@spec get_item(
  Ecto.UUID.t(),
  keyword()
) :: PhoenixKitCatalogue.Schemas.Item.t() | nil

Fetches an item by UUID. Returns nil if not found.

Options

  • :preload — list of associations to preload. Default []. Common smart-pricing preload: [catalogue_rules: :referenced_catalogue].

Examples

Catalogue.get_item(uuid)
Catalogue.get_item(uuid, preload: [:catalogue, catalogue_rules: :referenced_catalogue])

get_item!(uuid, opts \\ [])

Fetches an item by UUID with preloaded :catalogue, :category, and :manufacturer. Raises Ecto.NoResultsError if not found.

Pass :preload to add more associations (concatenated with the defaults).

get_manufacturer(uuid)

See PhoenixKitCatalogue.Catalogue.Manufacturers.get_manufacturer/1.

get_manufacturer!(uuid)

See PhoenixKitCatalogue.Catalogue.Manufacturers.get_manufacturer!/1.

get_pdf(uuid)

See PhoenixKitCatalogue.Catalogue.PdfLibrary.get_pdf/1.

get_pdf!(uuid)

See PhoenixKitCatalogue.Catalogue.PdfLibrary.get_pdf!/1.

get_pdf_extraction(pdf)

See PhoenixKitCatalogue.Catalogue.PdfLibrary.get_extraction/1.

get_supplier(uuid)

See PhoenixKitCatalogue.Catalogue.Suppliers.get_supplier/1.

get_supplier!(uuid)

See PhoenixKitCatalogue.Catalogue.Suppliers.get_supplier!/1.

get_translation(record, lang_code)

See PhoenixKitCatalogue.Catalogue.Translations.get_translation/2.

item_count_for_catalogue(catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Counts.item_count_for_catalogue/1.

item_count_for_category(category_uuid, opts \\ [])

@spec item_count_for_category(
  Ecto.UUID.t(),
  keyword()
) :: non_neg_integer()

Counts items in a single category (ignoring its catalogue scope).

Used by the infinite-scroll detail view to show the total under each category header (the number in "Category Name (N items)") without loading the items themselves.

Options

  • :mode:active (default) or :deleted

item_counts_by_catalogue()

See PhoenixKitCatalogue.Catalogue.Counts.item_counts_by_catalogue/0.

item_counts_by_category_for_catalogue(catalogue_uuid, opts \\ [])

@spec item_counts_by_category_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: %{required(Ecto.UUID.t()) => non_neg_integer()}

Returns a map of %{category_uuid => item_count} for every category in a catalogue in a single grouped query. Used by the infinite-scroll detail view so each category card can show its total count without a separate per-card round trip.

Items without a category (uncategorized) are excluded here — use uncategorized_count_for_catalogue/2 for those.

Options

  • :mode:active (default) or :deleted

item_pricing(item)

@spec item_pricing(PhoenixKitCatalogue.Schemas.Item.t()) :: %{
  base_price: Decimal.t() | nil,
  catalogue_markup: Decimal.t() | nil,
  item_markup: Decimal.t() | nil,
  markup_percentage: Decimal.t() | nil,
  sale_price: Decimal.t() | nil,
  catalogue_discount: Decimal.t() | nil,
  item_discount: Decimal.t() | nil,
  discount_percentage: Decimal.t() | nil,
  discount_amount: Decimal.t() | nil,
  final_price: Decimal.t() | nil
}

Returns the full pricing breakdown for an item within its catalogue.

Resolves both the catalogue's markup and discount (loading the catalogue association once if needed), then computes the sale price (after markup) and final price (after discount). The chain is base → markup → discount:

sale_price  = base_price * (1 + effective_markup   / 100)
final_price = sale_price  * (1 -  effective_discount / 100)

Never raises — if the catalogue can't be loaded (e.g. DB hiccup), falls back to 0% markup and 0% discount and logs a warning so the caller still gets a renderable result instead of crashing a template.

Returns a map with every field a pricing UI needs in one hop:

  • :base_price — the item's stored base price (or nil if unset)
  • :catalogue_markup — the catalogue's markup_percentage (the inherited default when the item has no override)
  • :item_markup — the item's markup override, or nil when inheriting from the catalogue
  • :markup_percentage — the markup actually applied (item override if set, otherwise catalogue's)
  • :sale_price — the price after markup, before any discount (or nil if no base price)
  • :catalogue_discount — the catalogue's discount_percentage
  • :item_discount — the item's discount override, or nil when inheriting from the catalogue
  • :discount_percentage — the discount actually applied (item override if set, otherwise catalogue's)
  • :discount_amount — the Decimal amount subtracted by the discount (sale_price - final_price), or nil if no discount applies or no base price
  • :final_price — the price after both markup and discount (or nil if no base price)

Examples

# Item inherits both markup (15%) and discount (10%)
Catalogue.item_pricing(item)
#=> %{
#=>   base_price: Decimal.new("100.00"),
#=>   catalogue_markup: Decimal.new("15.0"),
#=>   item_markup: nil,
#=>   markup_percentage: Decimal.new("15.0"),
#=>   sale_price: Decimal.new("115.00"),
#=>   catalogue_discount: Decimal.new("10.0"),
#=>   item_discount: nil,
#=>   discount_percentage: Decimal.new("10.0"),
#=>   discount_amount: Decimal.new("11.50"),
#=>   final_price: Decimal.new("103.50")
#=> }

# Item overrides discount to 0 — sale price is charged at full
Catalogue.item_pricing(item_with_zero_discount)
#=> %{..., final_price: Decimal.new("115.00"), discount_amount: Decimal.new("0.00"), ...}

linked_manufacturer_uuids(supplier_uuid)

See PhoenixKitCatalogue.Catalogue.Links.linked_manufacturer_uuids/1.

linked_supplier_uuids(manufacturer_uuid)

See PhoenixKitCatalogue.Catalogue.Links.linked_supplier_uuids/1.

list_all_categories()

@spec list_all_categories() :: [PhoenixKitCatalogue.Schemas.Category.t()]

Lists all non-deleted categories across all non-deleted catalogues, with breadcrumb-style names prefixed by their catalogue and every ancestor category (e.g. "Kitchen / Cabinets / Frames"). Useful for item move dropdowns where the user needs to distinguish same-named leaves under different parents.

Entries are grouped by catalogue (catalogues ordered by name) and within each catalogue returned in depth-first display order.

One query for catalogues + one query for all their categories — the tree walk and breadcrumb rewrite happen in memory. Safe to call on demand from move-dropdowns.

list_catalogue_rules(item_or_uuid)

See PhoenixKitCatalogue.Catalogue.Rules.list_catalogue_rules/1.

list_catalogues(opts \\ [])

@spec list_catalogues(keyword()) :: [PhoenixKitCatalogue.Schemas.Catalogue.t()]

Lists catalogues, ordered by name. Excludes deleted by default.

Options

  • :status — when provided, returns only catalogues with this exact status (e.g. "active", "archived", "deleted"). When nil (default), returns all non-deleted catalogues.
  • :kind — when provided, filters to a specific kind (:standard, :smart, or their string equivalents). When nil (default), returns all kinds.

Examples

Catalogue.list_catalogues()                     # active + archived
Catalogue.list_catalogues(status: "deleted")    # only deleted
Catalogue.list_catalogues(status: "active")     # only active
Catalogue.list_catalogues(kind: :smart)         # only smart catalogues
Catalogue.list_catalogues(kind: :standard)      # only standard catalogues

list_catalogues_by_name_prefix(prefix, opts \\ [])

@spec list_catalogues_by_name_prefix(
  String.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Catalogue.t()]

Lists catalogues whose name starts with prefix, case-insensitive.

Anchored at the start of the name — this is a prefix match (name ILIKE 'prefix%'), not a contains match. LIKE metacharacters (%, _) in the prefix are escaped.

Excludes deleted catalogues by default. Useful for narrowing a search scope: pair with search_items/2's :catalogue_uuids to search only the matched catalogues.

Options

  • :status — when provided, returns only catalogues with this exact status. Defaults to non-deleted (active + archived).
  • :limit — max results (no limit by default).

Examples

Catalogue.list_catalogues_by_name_prefix("Kit")
#=> [%Catalogue{name: "Kitchen Furniture"}, %Catalogue{name: "Kits"}]

Catalogue.list_catalogues_by_name_prefix("Kit", limit: 5)
Catalogue.list_catalogues_by_name_prefix("", limit: 10)  # returns first 10

# Compose with search
uuids =
  "Kit"
  |> Catalogue.list_catalogues_by_name_prefix()
  |> Enum.map(& &1.uuid)

Catalogue.search_items("oak", catalogue_uuids: uuids)

list_categories_for_catalogue(catalogue_uuid)

@spec list_categories_for_catalogue(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.Category.t()
]

Lists non-deleted categories for a catalogue, ordered by position then name.

Preloads items (non-deleted only).

list_categories_metadata_for_catalogue(catalogue_uuid, opts \\ [])

@spec list_categories_metadata_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Category.t()]

Lists categories for a catalogue without preloading items, ordered by position then name. Used by the infinite-scroll detail view to walk categories in display order without fetching potentially thousands of items up front.

Options

  • :mode:active (default, excludes deleted categories) or :deleted (all categories — deleted categories can still contain trashed items we want to show).

list_category_ancestors(category_uuid)

@spec list_category_ancestors(Ecto.UUID.t()) :: [
  PhoenixKitCatalogue.Schemas.Category.t()
]

Returns the list of ancestor categories from root down to (but not including) category_uuid. Empty when the category is a root. Useful for breadcrumbs.

list_category_tree(catalogue_uuid, opts \\ [])

@spec list_category_tree(
  Ecto.UUID.t(),
  keyword()
) :: [{PhoenixKitCatalogue.Schemas.Category.t(), non_neg_integer()}]

Returns the categories in a catalogue paired with their tree depth, in depth-first display order (position, then name, recursing into children). Each entry is {category, depth} where depth 0 means a root. Used to render flat parent-pickers and indented listings.

Options

  • :mode:active (default, excludes deleted categories) or :deleted (all statuses — the detail view in deleted mode still wants deleted categories that contain trashed items).
  • :exclude_subtree_of — skip a category and all its descendants (e.g. the category being edited — you can't parent it under itself or its descendants).

list_deleted_items_for_catalogue(catalogue_uuid, opts \\ [])

@spec list_deleted_items_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Lists soft-deleted items in a catalogue as a flat list, ordered by deletion date (most-recently-deleted first). updated_at is the deletion-time proxy — flipping status to "deleted" always bumps it. Used by the Items tab Deleted view, which surfaces a recency- ordered audit list rather than category-grouped cards.

Options

  • :limit — caps the list (default 500). Pagination isn't wired yet; if a catalogue routinely exceeds the limit, layer a cursor on top of this query.
  • :preload — extra associations on top of the default [:catalogue, category: :catalogue, manufacturer: []].

Examples

Catalogue.list_deleted_items_for_catalogue(catalogue_uuid)

list_items(opts \\ [])

@spec list_items(keyword()) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Lists all non-deleted items across all catalogues, ordered by name.

Preloads category (with catalogue) and manufacturer.

Options

  • :status — filter by status (e.g. "active", "inactive"). When nil (default), returns all non-deleted items.
  • :limit — max results to return (default: no limit)

Examples

Catalogue.list_items()                          # all non-deleted
Catalogue.list_items(status: "active")          # only active
Catalogue.list_items(limit: 100)                # first 100

list_items_by_uuids(uuids, opts \\ [])

@spec list_items_by_uuids(
  [Ecto.UUID.t()],
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Bulk-fetches items by a list of UUIDs. Excludes soft-deleted items. Result order matches the input UUID order; missing UUIDs are dropped (no nil placeholders, no error). Duplicate input UUIDs collapse to a single result.

Designed for snapshot rehydration — e.g. an order stored as a list of item UUIDs that needs full item data on reload. Avoids the N+1 trap of looping get_item/1 per UUID.

Options

  • :preload — extra associations appended to the default [:catalogue, :category, :manufacturer]. Pass [catalogue_rules: :referenced_catalogue] for smart-pricing.

Examples

Catalogue.list_items_by_uuids([uuid1, uuid2, uuid3])
Catalogue.list_items_by_uuids(uuids, preload: [catalogue_rules: :referenced_catalogue])

list_items_for_catalogue(catalogue_uuid, opts \\ [])

@spec list_items_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Lists non-deleted items for a catalogue, ordered by category position then item name. Includes uncategorized items (those with no category) at the end.

Default preloads [:catalogue, category: :catalogue, manufacturer: []]. Pass :preload in opts to add more — see list_items_for_category/2.

list_items_for_category(category_uuid, opts \\ [])

@spec list_items_for_category(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Lists non-deleted items for a category, ordered by position then name.

Default preloads [:catalogue, category: :catalogue, manufacturer: []]. Pass :preload in opts to add more (e.g. preload: [catalogue_rules: :referenced_catalogue] for smart-pricing consumers); the lists are concatenated, not replaced.

list_items_for_category_paged(category_uuid, opts \\ [])

@spec list_items_for_category_paged(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Lists a page of items for a single category, ordered by name.

Used by the infinite-scroll detail view; returns at most :limit items starting at :offset. Preloads :catalogue and :manufacturer so the table cell renderers can access them without extra queries.

Options

  • :mode:active (default, excludes deleted items) or :deleted (only deleted items)
  • :offset — default 0
  • :limit — default 50
  • :preload — extra associations appended to the default [:catalogue, :manufacturer].

list_items_referencing_catalogue(catalogue_uuid)

See PhoenixKitCatalogue.Catalogue.Rules.list_items_referencing_catalogue/1.

list_manufacturers(opts \\ [])

See PhoenixKitCatalogue.Catalogue.Manufacturers.list_manufacturers/1.

list_manufacturers_for_supplier(supplier_uuid)

See PhoenixKitCatalogue.Catalogue.Links.list_manufacturers_for_supplier/1.

list_move_target_categories(category)

Returns same-catalogue active categories that can receive items from a category about to be deleted (the category itself and its V103 descendants are excluded). Used by the admin "delete category" modal to populate the move-target dropdown.

Each entry is {category, depth}, depth-first order — the same shape list_category_tree/2 returns so callers can render the same indent rules.

list_pdfs(opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.list_pdfs/1.

list_suppliers(opts \\ [])

See PhoenixKitCatalogue.Catalogue.Suppliers.list_suppliers/1.

list_suppliers_for_manufacturer(manufacturer_uuid)

See PhoenixKitCatalogue.Catalogue.Links.list_suppliers_for_manufacturer/1.

list_uncategorized_items(catalogue_uuid, opts \\ [])

@spec list_uncategorized_items(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Lists uncategorized items (no category assigned) for a specific catalogue.

Options

  • :mode:active (default) excludes deleted items; :deleted returns only deleted items.
  • :preload — extra associations appended to the default [:catalogue, :manufacturer] preloads. Pass [catalogue_rules: :referenced_catalogue] for smart-pricing.

Examples

Catalogue.list_uncategorized_items(catalogue_uuid)
Catalogue.list_uncategorized_items(catalogue_uuid, mode: :deleted)

list_uncategorized_items_paged(catalogue_uuid, opts \\ [])

@spec list_uncategorized_items_paged(
  Ecto.UUID.t(),
  keyword()
) :: [PhoenixKitCatalogue.Schemas.Item.t()]

Lists a page of uncategorized items for a catalogue, ordered by name.

Same shape as list_items_for_category_paged/2, but for items where category_uuid IS NULL AND catalogue_uuid = ?. Used as the final section of the infinite-scroll detail view.

Options

  • :mode:active (default) or :deleted
  • :offset — default 0
  • :limit — default 50
  • :preload — extra associations appended to the default [:catalogue, :manufacturer].

more_pdf_matches_for_item(item, pdf_uuid, opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.more_pdf_matches_for_item/3.

move_category_to_catalogue(category, target_catalogue_uuid, opts \\ [])

@spec move_category_to_catalogue(
  PhoenixKitCatalogue.Schemas.Category.t(),
  Ecto.UUID.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()} | {:error, term()}

Moves a category — along with its entire subtree and every item inside — to a different catalogue.

The moved category's parent_uuid is cleared (it detaches from its former parent, which stays in the source catalogue) and it takes the next available root-level position in the target. Internal parent links inside the moved subtree are preserved.

Automatically assigns the next available root position in the target catalogue.

Examples

{:ok, moved} = Catalogue.move_category_to_catalogue(category, target_catalogue_uuid)

move_category_under(category, new_parent_uuid, opts \\ [])

@spec move_category_under(
  PhoenixKitCatalogue.Schemas.Category.t(),
  Ecto.UUID.t() | nil,
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error,
     :would_create_cycle
     | :cross_catalogue
     | :parent_not_found
     | Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Category.t())}

Reparents a category within the same catalogue, placing it under new_parent_uuid (or promoting it to a root with nil).

Rejects moves that would:

  • produce a cycle (new_parent_uuid is the category itself or one of its descendants) — returns {:error, :would_create_cycle}
  • cross a catalogue boundary — returns {:error, :cross_catalogue}. Callers who want that should run move_category_to_catalogue/3 first, then reparent.
  • target a missing parent — returns {:error, :parent_not_found}

The moved category takes the next-available position among its new siblings. Its subtree comes along untouched (parent links inside the subtree stay valid).

Passing new_parent_uuid = nil promotes the category to a root within its current catalogue.

Examples

{:ok, moved} = Catalogue.move_category_under(child, parent.uuid)
{:ok, moved} = Catalogue.move_category_under(child, nil)  # promote to root

move_item_and_reorder_destination(item, to_category_uuid, ordered_uuids, opts \\ [])

@spec move_item_and_reorder_destination(
  PhoenixKitCatalogue.Schemas.Item.t(),
  Ecto.UUID.t() | nil,
  [Ecto.UUID.t()],
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, :category_not_found | :wrong_scope | :too_many_uuids | term()}

Atomic combine of move_item_to_category/3 and reorder_items/4 for the cross-category drag-and-drop case.

The DnD path triggers two writes on a single drop: the moved item's category_uuid flips, and the destination category's position values get re-indexed to match the visual order. Calling the two context fns separately leaves a window where the move commits but the reorder rolls back, leaving the item in the new category with a stale position. Wrapping both in a single repo().transaction/1 closes that window — either both land or both roll back.

Calls the unlogged validate_and_apply_item_reorder_in_txn/3 so rejection / db-error audit rows are written outside the outer transaction. Otherwise a rejection inside the inner reorder would log a row that the outer rollback then discards, reopening the audit-trail gap.

Activity-log fan-out: item.moved lands inside the inner move_item_to_category/3 (rolled back if the reorder fails, which is correct — the move didn't actually happen). item.reordered lands here, after the outer transaction commits.

move_item_to_catalogue(item, catalogue_uuid, opts \\ [])

@spec move_item_to_catalogue(
  PhoenixKitCatalogue.Schemas.Item.t(),
  Ecto.UUID.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error,
     :catalogue_not_found
     | :same_catalogue
     | Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())}

Moves an item to a different catalogue, clearing its category.

Primarily used for smart items, where categories don't apply — the "where does this item live?" question reduces to "which catalogue?". Sets both catalogue_uuid and category_uuid in one update so the item becomes uncategorized within its new catalogue.

Returns {:error, :catalogue_not_found} if the target catalogue UUID doesn't resolve, {:error, :same_catalogue} if it's already there, or {:error, changeset} on validation failure. Logs an item.moved activity with from/to catalogue metadata.

Examples

{:ok, item} = Catalogue.move_item_to_catalogue(item, other_smart.uuid)

move_item_to_category(item, category_uuid, opts \\ [])

@spec move_item_to_category(
  PhoenixKitCatalogue.Schemas.Item.t(),
  Ecto.UUID.t() | nil,
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error,
     :category_not_found
     | Ecto.Changeset.t(PhoenixKitCatalogue.Schemas.Item.t())}

Moves an item to a different category.

If the target category lives in a different catalogue, the item's catalogue_uuid is updated to match. Passing nil for category_uuid detaches the item from any category while keeping it in its current catalogue.

Examples

{:ok, item} = Catalogue.move_item_to_category(item, new_category_uuid)
{:ok, item} = Catalogue.move_item_to_category(item, nil)  # make uncategorized

next_catalogue_position()

@spec next_catalogue_position() :: integer()

Returns the next available position for a new catalogue — one past the current max, falling back to 1 on an empty table.

next_category_position(catalogue_uuid, parent_uuid \\ nil)

@spec next_category_position(Ecto.UUID.t(), Ecto.UUID.t() | nil) :: non_neg_integer()

Returns the next available position for a new category among its siblings. Position is scoped to (catalogue_uuid, parent_uuid) — the set of categories sharing the same parent within a catalogue — since V103's nested-category tree makes a single catalogue-wide ordering ambiguous.

parent_uuid defaults to nil, i.e. root-level siblings. Returns 0 if no siblings exist at that level, otherwise max_position + 1.

next_item_position(catalogue_uuid, category_uuid)

@spec next_item_position(Ecto.UUID.t(), Ecto.UUID.t() | nil) :: integer()

Returns the next available position for a new item within a scope.

Items are scoped to (catalogue_uuid, category_uuid). Pass category_uuid: nil for the uncategorized bucket of a catalogue.

permanently_delete_catalogue(catalogue, opts \\ [])

@spec permanently_delete_catalogue(
  PhoenixKitCatalogue.Schemas.Catalogue.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()}
  | {:error, {:referenced_by_smart_items, non_neg_integer()}}
  | {:error, term()}

Permanently deletes a catalogue and all its contents from the database.

Cascades downward in a transaction:

  1. Hard-deletes all items in the catalogue's categories
  2. Hard-deletes all categories
  3. Hard-deletes the catalogue

Refuses with {:error, {:referenced_by_smart_items, count}} when one or more smart-catalogue items still have rules pointing at this catalogue. V102's ON DELETE CASCADE would silently wipe those rule rows; callers should resolve the references explicitly (or remove the rules) before retrying. Use :force to bypass this guard at your own risk.

This cannot be undone.

Options

  • :actor_uuid — UUID to attribute on the activity log
  • :force — when true, deletes even if smart-rule references exist

Examples

{:ok, _} = Catalogue.permanently_delete_catalogue(catalogue)
{:error, {:referenced_by_smart_items, 3}} =
  Catalogue.permanently_delete_catalogue(catalogue_with_refs)

permanently_delete_category(category, opts \\ [])

@spec permanently_delete_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Category.t()} | {:error, term()}

Permanently deletes a category and its entire subtree (all descendant categories + every item in any of them) from the database.

Cascades downward in a transaction, following the nested-category tree introduced in V103. Items are hard-deleted first, then the subtree categories from leaves up (ordered so child FKs resolve before their parent is removed). This cannot be undone.

permanently_delete_item(item, opts \\ [])

@spec permanently_delete_item(
  PhoenixKitCatalogue.Schemas.Item.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Item.t()} | {:error, term()}

Permanently deletes an item from the database. This cannot be undone.

Examples

{:ok, _} = Catalogue.permanently_delete_item(item)

permanently_delete_pdf(pdf, opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.permanently_delete_pdf/2.

prune_orphan_pdf_page_contents()

See PhoenixKitCatalogue.Catalogue.PdfLibrary.prune_orphan_page_contents/0.

put_catalogue_rules(item, rules, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Rules.put_catalogue_rules/3.

reorder_catalogue_rules(item_uuid, ordered_referenced_uuids, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Rules.reorder_catalogue_rules/3.

reorder_catalogues(ordered_uuids, opts \\ [])

@spec reorder_catalogues(
  [Ecto.UUID.t()],
  keyword()
) :: :ok | {:error, :too_many_uuids | term()}

Re-indexes the supplied list of catalogue UUIDs into positions 1..N. Used by the catalogues index DnD handler.

UUIDs missing from the table are skipped. The whole pass runs in one transaction. Returns :ok on success or {:error, reason} on transaction failure.

reorder_categories(catalogue_uuid, parent_uuid, ordered_uuids, opts \\ [])

@spec reorder_categories(
  Ecto.UUID.t(),
  Ecto.UUID.t() | nil,
  [Ecto.UUID.t()],
  keyword()
) ::
  :ok | {:error, :not_siblings | :too_many_uuids | term()}

Re-indexes a sibling group of categories from a list of UUIDs.

Sibling scope is (catalogue_uuid, parent_uuid) — the same scope used by swap_category_positions/2 and next_category_position/2. The function loads the supplied categories, verifies they all share that scope, and writes positions 1..N in the order given. UUIDs not found in the table are ignored; UUIDs that don't share the scope abort the whole batch with {:error, :not_siblings}.

Two-pass updates inside a single transaction — the first pass writes negative positions to dodge any future unique index on (catalogue_uuid, parent_uuid, position); the second pass writes the final positive values. If no such index exists today, the cost is one extra UPDATE per row, which is cheap relative to the LV round-trip that triggers the call.

reorder_categories_groups(catalogue_uuid, groups, opts \\ [])

@spec reorder_categories_groups(
  Ecto.UUID.t(),
  [{Ecto.UUID.t() | nil, [Ecto.UUID.t()]}],
  keyword()
) :: :ok | {:error, :too_many_uuids | :not_siblings | term()}

Re-indexes multiple sibling groups of categories in one outer transaction — the LV layer hits this when a single drop touches more than one parent group.

Each group is {parent_uuid_or_nil, [uuid]}. All groups are validated up front (cap + sibling scope) before any writes; if any group fails validation, the whole batch returns the error and no writes happen.

Atomicity: a DB-level failure in any group rolls back every group. Beats the previous LV-side Enum.reduce over per-group calls, which committed groups one at a time and could leave partial state.

reorder_items(catalogue_uuid, category_uuid, ordered_uuids, opts \\ [])

@spec reorder_items(Ecto.UUID.t(), Ecto.UUID.t() | nil, [Ecto.UUID.t()], keyword()) ::
  :ok | {:error, :wrong_scope | :too_many_uuids | term()}

Re-indexes the items inside a (catalogue_uuid, category_uuid) bucket. Pass category_uuid: nil to reorder the uncategorized bucket. Behaves like reorder_categories/4: validates scope, runs two passes inside a transaction, logs an activity row.

UUIDs that don't belong to the scope abort with {:error, :wrong_scope} so a stale DOM can't bleed reorder writes across catalogues.

restore_catalogue(catalogue, opts \\ [])

@spec restore_catalogue(
  PhoenixKitCatalogue.Schemas.Catalogue.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()} | {:error, term()}

Restores a soft-deleted catalogue by setting its status to "active".

Cascades downward in a transaction:

  1. All deleted categories → status "active"
  2. All deleted items in those categories → status "active"
  3. The catalogue itself → status "active"

Examples

{:ok, catalogue} = Catalogue.restore_catalogue(catalogue)

restore_category(category, opts \\ [])

@spec restore_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error, :parent_catalogue_deleted | term()}

Restores a soft-deleted category by flipping its status back to "active". No cascades — each entity owns its own status, so restore-as-undo doesn't ripple sideways.

  • Refuses with {:error, :parent_catalogue_deleted} when the category's parent catalogue is itself deleted. The operator must restore the catalogue explicitly first.
  • Items keep their (deleted) status. Items that were trashed via the prior :cascade disposition stay deleted; the operator restores them individually from the Items-tab Deleted view, where restore_item/2 routes them through the now-active parent (or detaches them to Uncategorized if some intermediate parent is still deleted).
  • Descendant categories keep their (deleted) status. list_category_tree/2's orphan-promotion will surface this re-active leaf as a root if all its ancestors are still deleted.
  • Ancestor categories keep their (active or deleted) status. The only ancestor we check is the parent catalogue (above).

Activity log records category.restored with name and catalogue_uuid only — no subtree_size / items_cascaded, since the answer is always 0 under the no-cascade rule.

Examples

{:ok, _} = Catalogue.restore_category(category)
{:error, :parent_catalogue_deleted} =
  Catalogue.restore_category(category_under_deleted_catalogue)

restore_item(item, opts \\ [])

@spec restore_item(
  PhoenixKitCatalogue.Schemas.Item.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Item.t()}
  | {:error, :parent_catalogue_deleted | term()}

Restores a soft-deleted item by setting its status to "active".

Refuses with {:error, :parent_catalogue_deleted} when the item's parent catalogue is itself deleted — the operator must restore the catalogue first. (An item cannot exist outside a catalogue.)

When the parent catalogue is active but the item's category is deleted, the item is uncategorized on restore: category_uuid is set to nil so the item resurfaces in the catalogue's Uncategorized bucket. This avoids the surprising side-effect of auto-reviving the whole category structure. If the user wants the category back, they restore the category explicitly (which cascades downward and brings the item with it via category_uuid matching).

Examples

{:ok, item} = Catalogue.restore_item(item)
{:error, :parent_catalogue_deleted} =
  Catalogue.restore_item(item_under_deleted_catalogue)

restore_pdf(pdf, opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.restore_pdf/2.

search_items(query, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Search.search_items/2.

search_items_in_catalogue(catalogue_uuid, query, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Search.search_items_in_catalogue/3.

search_items_in_category(category_uuid, query, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Search.search_items_in_category/3.

search_pdfs_for_item(item, opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.search_pdfs_for_item/2.

set_translation(record, lang_code, field_data, update_fn, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Translations.set_translation/5.

swap_category_positions(cat_a, cat_b, opts \\ [])

@spec swap_category_positions(
  PhoenixKitCatalogue.Schemas.Category.t(),
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) :: {:ok, term()} | {:error, :not_siblings | term()}

Atomically swaps the positions of two categories within a transaction.

Positions are scoped to (catalogue_uuid, parent_uuid) sibling groups (V103). Swapping positions of categories that are not siblings would mix two independent ordering axes, so this function refuses with {:error, :not_siblings} when the categories live under different parents or in different catalogues. The detail-view reorder buttons enforce the same constraint at the LV level; this is the context-level guard for any programmatic caller.

Examples

{:ok, _} = Catalogue.swap_category_positions(cat_a, cat_b)
{:error, :not_siblings} = Catalogue.swap_category_positions(root, child)

sync_manufacturer_suppliers(manufacturer_uuid, supplier_uuids, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Links.sync_manufacturer_suppliers/3.

sync_supplier_manufacturers(supplier_uuid, manufacturer_uuids, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Links.sync_supplier_manufacturers/3.

trash_catalogue(catalogue, opts \\ [])

@spec trash_catalogue(
  PhoenixKitCatalogue.Schemas.Catalogue.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Catalogue.t()} | {:error, term()}

Soft-deletes a catalogue by setting its status to "deleted".

Cascades downward in a transaction:

  1. All non-deleted items in the catalogue's categories → status "deleted"
  2. All non-deleted categories → status "deleted"
  3. The catalogue itself → status "deleted"

Examples

{:ok, catalogue} = Catalogue.trash_catalogue(catalogue)

trash_category(category, opts \\ [])

@spec trash_category(
  PhoenixKitCatalogue.Schemas.Category.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Category.t()}
  | {:error, :move_target_not_found | :cross_catalogue_move | term()}

Soft-deletes a category and its entire subtree by setting their status to "deleted".

Cascades the categories downward in a transaction (the category itself and every descendant flip to "deleted"), following the V103 nested-category tree.

Items in the subtree are handled per the :items opt:

  • :cascade (default) — items in the subtree flip to "deleted" alongside the categories. Original behavior, kept for programmatic callers + admin "delete and trash everything" intent.
  • :uncategorize — items in the subtree keep their catalogue_uuid but get category_uuid: nil, surviving the category trash. Used by the admin modal when the operator wants the category gone but the items kept in the same catalogue.
  • {:move_to, target_uuid} — items move to the target category (which must live in the same catalogue) before the category is trashed. Cross-catalogue moves aren't supported here; the LV restricts the dropdown to same-catalogue targets.

Logs a single category.trashed activity on the root with subtree_size, items_handled, and items_disposition in metadata.

Examples

{:ok, _} = Catalogue.trash_category(category)
{:ok, _} = Catalogue.trash_category(category, items: :uncategorize)
{:ok, _} = Catalogue.trash_category(category, items: {:move_to, target_uuid})

trash_item(item, opts \\ [])

Soft-deletes an item by setting its status to "deleted".

Examples

{:ok, item} = Catalogue.trash_item(item)

trash_items_in_category(category_uuid, opts \\ [])

@spec trash_items_in_category(
  Ecto.UUID.t(),
  keyword()
) :: {non_neg_integer(), nil}

Bulk soft-deletes all non-deleted items in a category.

Returns {count, nil} where count is the number of items affected.

Examples

{3, nil} = Catalogue.trash_items_in_category(category_uuid)

trash_pdf(pdf, opts \\ [])

See PhoenixKitCatalogue.Catalogue.PdfLibrary.trash_pdf/2.

uncategorized_count_for_catalogue(catalogue_uuid, opts \\ [])

@spec uncategorized_count_for_catalogue(
  Ecto.UUID.t(),
  keyword()
) :: non_neg_integer()

Counts non-deleted uncategorized items for a catalogue (items with category_uuid IS NULL). Used to decide whether the infinite-scroll detail view needs to show an "Uncategorized" card at all.

update_catalogue(catalogue, attrs, opts \\ [])

Updates a catalogue with the given attributes.

update_catalogue_rule(rule, attrs, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Rules.update_catalogue_rule/3.

update_category(category, attrs, opts \\ [])

Updates a category with the given attributes.

update_item(item, attrs, opts \\ [])

Updates an item with the given attributes.

update_manufacturer(manufacturer, attrs, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Manufacturers.update_manufacturer/3.

update_supplier(supplier, attrs, opts \\ [])

See PhoenixKitCatalogue.Catalogue.Suppliers.update_supplier/3.