# `PhoenixKitCatalogue.Catalogue.Attributes`
[🔗](https://github.com/BeamLabEU/phoenix_kit_catalogue/blob/v0.18.0/lib/phoenix_kit_catalogue/catalogue/attributes.ex#L1)

Attribute groups — reusable, translatable sets of product characteristics.

A group ("Idea doors") owns attributes ("Color", "Trim"), each attribute
owns ordered values ("White", "Oak"); an item is linked to one group
through `phoenix_kit_cat_item_attribute_groups` and inherits everything
the group defines. This is an evolution of the hand-typed per-item
metadata (`item.data["meta"]`), which stays untouched and is surfaced
read/editable by the item form's legacy collapse.

## Identity and translations

`key` slugs (auto-generated from the primary-language name via
`PhoenixKit.Utils.Slug`, immutable after creation) plus row UUIDs are the
durable identity — future exclusion rules and parent-app order lines
reference them, so editing a translation never changes what old data
means. Display names ride the module's multilang `data` JSONB convention
(primary language in the `name`/`value` columns, other languages in
`data`); resolve them with `resolved_group/2`.

## Deletion vs archive

All three definition levels carry `status` (`"active"` / `"archived"`).
Archive is the path for anything in use: the DB RESTRICTs deleting a
group any item references, and `delete_attribute_group/2` performs the
values → attributes → group cascade explicitly in one transaction
(mirroring `permanently_delete_catalogue/2`'s gate-then-cascade shape)
rather than trusting a silent DB cascade.

## Downstream contract

Order lines in the parent app that record a chosen value must snapshot
the resolved labels and keys at order time — the UUID reference alone is
identity, not history.

Public surface is re-exported from `PhoenixKitCatalogue.Catalogue` via
`defdelegate`.

# `assignment_counts`

```elixir
@spec assignment_counts([Ecto.UUID.t()]) :: %{
  required(Ecto.UUID.t()) =&gt; non_neg_integer()
}
```

How many items are assigned to each of the given groups — one grouped
query. Returns `%{group_uuid => count}`; drives the "in use" gate and
the list page's usage column.

# `attribute_counts`

```elixir
@spec attribute_counts([Ecto.UUID.t()]) :: %{
  required(Ecto.UUID.t()) =&gt; non_neg_integer()
}
```

Per-group attribute counts for the groups list — one grouped query,
active attributes only. Returns `%{group_uuid => count}`.

# `change_attribute_group`

```elixir
@spec change_attribute_group(PhoenixKitCatalogue.Schemas.AttributeGroup.t(), map()) ::
  Ecto.Changeset.t()
```

Returns a changeset for tracking attribute-group form changes.

# `create_attribute`

```elixir
@spec create_attribute(
  PhoenixKitCatalogue.Schemas.AttributeGroup.t(),
  map(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.Attribute.t()}
  | {:error, Ecto.Changeset.t()}
```

Adds an attribute to a group. The stable `key` slug is generated from
the given name (deduped within the group); position appends at the end.

# `create_attribute_group`

```elixir
@spec create_attribute_group(
  map(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.AttributeGroup.t()}
  | {:error, Ecto.Changeset.t()}
```

Creates an attribute group.

# `create_attribute_value`

```elixir
@spec create_attribute_value(PhoenixKitCatalogue.Schemas.Attribute.t(), map()) ::
  {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()}
  | {:error, Ecto.Changeset.t()}
```

Adds a value to an attribute. The stable `key` slug is generated from
the display text (deduped within the attribute); position appends at the
end; the attribute's first value becomes the default automatically.

# `delete_attribute`

```elixir
@spec delete_attribute(
  PhoenixKitCatalogue.Schemas.Attribute.t(),
  keyword()
) :: {:ok, PhoenixKitCatalogue.Schemas.Attribute.t()} | {:error, term()}
```

Deletes an attribute and its values in one transaction.

# `delete_attribute_group`

```elixir
@spec delete_attribute_group(
  PhoenixKitCatalogue.Schemas.AttributeGroup.t(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.AttributeGroup.t()}
  | {:error, :in_use | Ecto.Changeset.t()}
```

Hard-deletes a group with an explicit values → attributes → group
cascade in one transaction.

Gated: returns `{:error, :in_use}` when any item is assigned to the
group (the assignment FK would RESTRICT anyway — the gate turns the
constraint error into a domain answer). Archive is the path for groups
in use.

# `delete_attribute_value`

```elixir
@spec delete_attribute_value(PhoenixKitCatalogue.Schemas.AttributeValue.t()) ::
  {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()} | {:error, term()}
```

Deletes a value. If it was the default, the lowest-position remaining
active value is promoted so a `multi` attribute never silently loses
its default.

# `get_attribute`

```elixir
@spec get_attribute(Ecto.UUID.t()) :: PhoenixKitCatalogue.Schemas.Attribute.t() | nil
```

Fetches an attribute by UUID (with its group). Returns `nil` if not found.

# `get_attribute_group`

```elixir
@spec get_attribute_group(Ecto.UUID.t()) ::
  PhoenixKitCatalogue.Schemas.AttributeGroup.t() | nil
```

Fetches an attribute group by UUID. Returns `nil` if not found.

# `get_attribute_group_full`

```elixir
@spec get_attribute_group_full(Ecto.UUID.t()) ::
  PhoenixKitCatalogue.Schemas.AttributeGroup.t() | nil
```

Fetches a group with ALL its attributes and values preloaded in position
order, archived rows included — the group editor's working set. Consumer
paths (item preview, product card) want `resolved_group/2` instead.

# `get_attribute_value`

```elixir
@spec get_attribute_value(Ecto.UUID.t()) ::
  PhoenixKitCatalogue.Schemas.AttributeValue.t() | nil
```

Fetches a value by UUID (with its attribute). Returns `nil` if not found.

# `get_item_attribute_group_uuid`

```elixir
@spec get_item_attribute_group_uuid(Ecto.UUID.t()) :: Ecto.UUID.t() | nil
```

The item's current assignment (or `nil`) — one indexed lookup.

# `item_attribute_group_map`

```elixir
@spec item_attribute_group_map([Ecto.UUID.t()]) :: %{
  required(Ecto.UUID.t()) =&gt; Ecto.UUID.t()
}
```

Batch map of `%{item_uuid => attribute_group_uuid}` for the given items —
one indexed query; drives the list/card indicator chips with no per-row
lookups.

# `list_attribute_groups`

```elixir
@spec list_attribute_groups(keyword()) :: [
  PhoenixKitCatalogue.Schemas.AttributeGroup.t()
]
```

Lists attribute groups ordered by position, then name.

## Options

  * `:status` — filter by status (`"active"` / `"archived"`); nil = all.

# `reorder_attribute_values`

```elixir
@spec reorder_attribute_values(PhoenixKitCatalogue.Schemas.Attribute.t(), [
  Ecto.UUID.t()
]) :: :ok
```

Persists a manual ordering of an attribute's values (same contract as `reorder_attributes/2`).

# `reorder_attributes`

```elixir
@spec reorder_attributes(PhoenixKitCatalogue.Schemas.AttributeGroup.t(), [
  Ecto.UUID.t()
]) :: :ok
```

Persists a manual ordering of a group's attributes. UUIDs not in the
list keep their position; unknown UUIDs are dropped BEFORE any writes —
the client list is forgeable, so the write count is bounded by the
group's real row count, never by payload length (panel finding).

# `resolved_group`

```elixir
@spec resolved_group(Ecto.UUID.t() | nil, String.t()) :: map() | nil
```

The programmatic read surface: a group resolved for display in `lang` —
active attributes in position order, each with its active values in
position order, names/labels translated with primary-language fallback.

Returns `nil` when the group doesn't exist. Shape:

    %{
      uuid: ..., key-less group: name: "Idea doors",
      attributes: [
        %{uuid: ..., key: "color", name: "Цвет", kind: "multi",
          values: [%{uuid: ..., key: "oak", value: "Дуб", default?: true}, ...]},
        ...
      ]
    }

This is what the item form preview, the product card, and (later) the
parent app's order-line picker all consume — module boundaries stay at
the context, not at raw table access.

# `set_default_value`

```elixir
@spec set_default_value(PhoenixKitCatalogue.Schemas.AttributeValue.t()) ::
  {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()} | {:error, term()}
```

Makes a value its attribute's default. Unset-then-set inside one
transaction (the partial unique index allows at most one default);
fails with `{:error, :not_found}` when the value vanished concurrently
and `{:error, :conflict}` when two flips race on the index.

# `set_item_attribute_group`

```elixir
@spec set_item_attribute_group(
  PhoenixKitCatalogue.Schemas.Item.t(),
  Ecto.UUID.t() | nil,
  keyword()
) ::
  {:ok, :assigned | :cleared | :unchanged}
  | {:error, :invalid_group | Ecto.Changeset.t()}
```

Sets (or clears, with `nil`) the item's attribute group.

Validates the group exists and is active — except that keeping the
item's CURRENT group is always allowed even when that group has been
archived since (the stale-select rule: an archived assignment renders,
it just can't be newly chosen). Returns `{:error, :invalid_group}` for
anything else.

# `update_attribute`

```elixir
@spec update_attribute(PhoenixKitCatalogue.Schemas.Attribute.t(), map()) ::
  {:ok, PhoenixKitCatalogue.Schemas.Attribute.t()}
  | {:error, Ecto.Changeset.t()}
```

Updates an attribute (name, translations, kind, status, position). `key` is immutable.

# `update_attribute_group`

```elixir
@spec update_attribute_group(
  PhoenixKitCatalogue.Schemas.AttributeGroup.t(),
  map(),
  keyword()
) ::
  {:ok, PhoenixKitCatalogue.Schemas.AttributeGroup.t()}
  | {:error, Ecto.Changeset.t()}
```

Updates an attribute group (name, translations, status, position).

# `update_attribute_value`

```elixir
@spec update_attribute_value(PhoenixKitCatalogue.Schemas.AttributeValue.t(), map()) ::
  {:ok, PhoenixKitCatalogue.Schemas.AttributeValue.t()}
  | {:error, Ecto.Changeset.t()}
```

Updates a value's display text / translations / status. `key` is immutable.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
