Context module for managing the Document Creator Category → Type hierarchy.
Provides CRUD, cascade soft-delete/restore, reorder, and picker helpers
for Category and Type schemas. Modeled on
PhoenixKitCatalogue.Catalogue.
Soft-Delete Cascade
trash_category/1— soft-deletes the category, all its types, and all templates reachable viacategory_uuidortype_uuid. Records affected template uuids in the activity log sorestore_category/1can scope its restore precisely.trash_type/1— soft-deletes the type and templates whosetype_uuidpoints at it.- Documents are never cascaded; they keep pointing at their (now trashed) category and remain usable.
PubSub
Every successful write broadcasts {:doc_taxonomy_changed, level, uuid}
on the shared PhoenixKit PubSub. level is :category or :type.
Consumers subscribe via PhoenixKit.PubSubHelper.subscribe/1.
Summary
Functions
Returns [{label, value}] for all active categories, ordered by position,
preceded by a {"No category", nil} empty option.
Counts categories, applying the same :status filter semantics as
list_categories/1. Counts in SQL instead of loading rows — use when only
the number is needed (e.g. a "Trash (N)" badge).
Counts types for a category, applying the same :status filter semantics as
list_types_for_category/2. Counts in SQL instead of loading rows.
Creates a category.
Creates a type.
Returns the canonical ANDI group names, in order.
Seeds (idempotently) the canonical ANDI group set under category_uuid,
positioned 0..8 in default_group_order/0 order.
Fetches a type by UUID, but only when it is currently active. Returns
nil for trashed types as well as missing uuids.
Fetches a category by UUID. Returns nil if not found.
Fetches a category by UUID. Raises Ecto.NoResultsError if not found.
Fetches a type by UUID. Returns nil if not found.
Fetches a type by UUID. Raises Ecto.NoResultsError if not found.
Lists categories ordered by position then name.
Returns a list of {category, [types]} tuples for all active categories,
ordered by position. Types within each category are ordered by position.
Lists a template's category memberships (one row per category, each with
an optional group type_uuid), oldest first.
Lists types for a category ordered by position then name.
Returns the display name of a Category or Type record translated into
locale, reading overrides from the multilang data JSONB via
PhoenixKit.Utils.Multilang.get_language_data/2.
Batch variant of list_memberships_for_template/1 for a grid render:
returns %{template_uuid => [%{category_uuid: _, type_uuid: _}, ...]} for
the given template uuids in a single query (avoids a per-row query).
Permanently deletes a category and all its types from the database.
Permanently deletes a type from the database.
Reorders categories by assigning positions from the given ordered list.
Reorders types within a category by assigning positions from the given ordered list.
Restores a soft-deleted category.
Restores a soft-deleted type and templates trashed by its cascade.
Replaces a template's full set of category memberships in one transaction.
Subscribes the caller to taxonomy change events.
Soft-deletes a category by setting its status to "deleted".
Soft-deletes a type by setting its status to "deleted".
Returns [{label, value}] for all active types within a category, ordered
by position, preceded by a {"No type", nil} empty option.
Updates a category with the given attributes.
Updates a type with the given attributes.
Functions
@spec category_options(String.t() | nil) :: [{String.t(), Ecto.UUID.t() | nil}]
Returns [{label, value}] for all active categories, ordered by position,
preceded by a {"No category", nil} empty option.
Suitable for options_for_select/2. The empty option lets users clear the
FK (which is nullable).
locale selects the translated label via localized_name/2; omit (or
pass nil) to fall back to the denormalized name column.
Drop-in replacement for the hard-coded category_options/0 in
documents_live.ex.
@spec count_categories(keyword()) :: non_neg_integer()
Counts categories, applying the same :status filter semantics as
list_categories/1. Counts in SQL instead of loading rows — use when only
the number is needed (e.g. a "Trash (N)" badge).
@spec count_types_for_category( Ecto.UUID.t(), keyword() ) :: non_neg_integer()
Counts types for a category, applying the same :status filter semantics as
list_types_for_category/2. Counts in SQL instead of loading rows.
@spec create_category( map(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Category.t()} | {:error, Ecto.Changeset.t(PhoenixKitDocumentCreator.Schemas.Category.t())}
Creates a category.
Required attributes
:name— category name (1-255 chars)
Optional attributes
:description,:position(default: appended after the last active category),:status(default"active"),:data
@spec create_type( map(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Type.t()} | {:error, Ecto.Changeset.t(PhoenixKitDocumentCreator.Schemas.Type.t())}
Creates a type.
Required attributes
:name— type name (1-255 chars):category_uuid— parent category
Optional attributes
:description,:position(default: appended after the last active type in the same category),:status,:data
@spec default_group_order() :: [String.t()]
Returns the canonical ANDI group names, in order.
@spec ensure_default_group_order( Ecto.UUID.t(), keyword() ) :: :ok | {:error, term()}
Seeds (idempotently) the canonical ANDI group set under category_uuid,
positioned 0..8 in default_group_order/0 order.
- A missing canonical group is created at its canonical position.
- An existing active group with a canonical name is repositioned (never duplicated).
- Any other active groups under the category are pushed after the canonical block (positions continue from 9) so the canonical order is collision-free.
Runs in one transaction and broadcasts a single :type change. This is the
code seed for part А — call it per category (e.g. from Admin → Documents →
Categories) instead of dragging the 9 groups into order by hand.
@spec get_active_type(Ecto.UUID.t()) :: PhoenixKitDocumentCreator.Schemas.Type.t() | nil
Fetches a type by UUID, but only when it is currently active. Returns
nil for trashed types as well as missing uuids.
get_type/1 returns the row regardless of status, which is correct for
callers that need the record itself (e.g. restoring or permanently
deleting it) but wrong for anywhere a type's name is rendered as a live
label for end users — that would surface a soft-deleted type's name as
if it were still a normal, selectable option. Use get_active_type/1 in
those spots instead.
@spec get_category(Ecto.UUID.t()) :: PhoenixKitDocumentCreator.Schemas.Category.t() | nil
Fetches a category by UUID. Returns nil if not found.
@spec get_category!(Ecto.UUID.t()) :: PhoenixKitDocumentCreator.Schemas.Category.t()
Fetches a category by UUID. Raises Ecto.NoResultsError if not found.
@spec get_type(Ecto.UUID.t()) :: PhoenixKitDocumentCreator.Schemas.Type.t() | nil
Fetches a type by UUID. Returns nil if not found.
@spec get_type!(Ecto.UUID.t()) :: PhoenixKitDocumentCreator.Schemas.Type.t()
Fetches a type by UUID. Raises Ecto.NoResultsError if not found.
@spec list_categories(keyword()) :: [PhoenixKitDocumentCreator.Schemas.Category.t()]
Lists categories ordered by position then name.
Options
:status— when provided, returns only categories with this exact status (e.g."active","deleted"). When nil (default), returns all non-deleted categories.
@spec list_category_tree() :: [ {PhoenixKitDocumentCreator.Schemas.Category.t(), [PhoenixKitDocumentCreator.Schemas.Type.t()]} ]
Returns a list of {category, [types]} tuples for all active categories,
ordered by position. Types within each category are ordered by position.
Useful for building grouped pickers in LiveViews.
@spec list_memberships_for_template(Ecto.UUID.t()) :: [ PhoenixKitDocumentCreator.Schemas.TemplateTaxonomy.t() ]
Lists a template's category memberships (one row per category, each with
an optional group type_uuid), oldest first.
@spec list_types_for_category( Ecto.UUID.t(), keyword() ) :: [PhoenixKitDocumentCreator.Schemas.Type.t()]
Lists types for a category ordered by position then name.
Options
:status— when provided, returns only types with this exact status. Defaults to non-deleted types.
@spec localized_name( PhoenixKitDocumentCreator.Schemas.Category.t() | PhoenixKitDocumentCreator.Schemas.Type.t() | map(), String.t() | nil ) :: String.t()
Returns the display name of a Category or Type record translated into
locale, reading overrides from the multilang data JSONB via
PhoenixKit.Utils.Multilang.get_language_data/2.
Mirrors PhoenixKitCatalogue.Catalogue.get_translation/2 — falls back to
the primary-language value, then to the denormalized name column.
Returns record.name unchanged when locale is nil or no translation
data exists yet.
@spec memberships_by_templates([Ecto.UUID.t()]) :: %{ optional(Ecto.UUID.t()) => [ %{category_uuid: Ecto.UUID.t(), type_uuid: Ecto.UUID.t() | nil} ] }
Batch variant of list_memberships_for_template/1 for a grid render:
returns %{template_uuid => [%{category_uuid: _, type_uuid: _}, ...]} for
the given template uuids in a single query (avoids a per-row query).
@spec permanently_delete_category( PhoenixKitDocumentCreator.Schemas.Category.t(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Category.t()} | {:error, term()}
Permanently deletes a category and all its types from the database.
Relies on ON DELETE CASCADE for child types and ON DELETE SET NULL for
template/document FK columns.
@spec permanently_delete_type( PhoenixKitDocumentCreator.Schemas.Type.t(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Type.t()} | {:error, term()}
Permanently deletes a type from the database.
Relies on ON DELETE SET NULL for template/document FK columns.
@spec reorder_categories( [Ecto.UUID.t()], keyword() ) :: :ok | {:error, term()}
Reorders categories by assigning positions from the given ordered list.
Each uuid in the list gets position = index. UUIDs not present keep
their existing positions.
@spec reorder_types(Ecto.UUID.t(), [Ecto.UUID.t()], keyword()) :: :ok | {:error, term()}
Reorders types within a category by assigning positions from the given ordered list.
@spec restore_category( PhoenixKitDocumentCreator.Schemas.Category.t(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Category.t()} | {:error, term()}
Restores a soft-deleted category.
Cascades in one transaction:
- Category
status → "active" - Types whose uuids were recorded in the trash activity log
status → "active"(only those, so types the user had trashed manually before the cascade stay trashed) - Templates whose uuids were recorded in the trash activity log
status → "published"(only those, to avoid restoring manually trashed templates)
When PhoenixKit.Activity is not loaded (or no matching activity entry
exists), cascade-trashed types and templates are not restored — they
must be restored manually.
@spec restore_type( PhoenixKitDocumentCreator.Schemas.Type.t(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Type.t()} | {:error, term()}
Restores a soft-deleted type and templates trashed by its cascade.
@spec set_template_memberships(Ecto.UUID.t(), [map()], keyword()) :: {:ok, [PhoenixKitDocumentCreator.Schemas.TemplateTaxonomy.t()]} | {:error, term()}
Replaces a template's full set of category memberships in one transaction.
memberships is a list of maps with a :category_uuid (required) and an
optional :type_uuid group (atom or string keys accepted). The write is
replace-all: existing rows for the template are deleted first, then the
given set is inserted. An empty list clears all memberships.
After the swap, the primary membership — the one whose category has
the lowest Category.position — is mirrored into the legacy
templates.category_uuid/type_uuid columns so backward-compatible
single-binding readers keep working (an empty set clears the mirror).
Broadcasts {:doc_taxonomy_changed, :template, template_uuid}.
@spec subscribe() :: :ok | {:error, term()}
Subscribes the caller to taxonomy change events.
@spec trash_category( PhoenixKitDocumentCreator.Schemas.Category.t(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Category.t()} | {:error, term()}
Soft-deletes a category by setting its status to "deleted".
Cascades in one transaction:
- Category
status → "deleted" - Its currently-active types
status → "deleted" - All templates reachable via
category_uuid(directly) or via any of the category'stype_uuidvalues →status → "trashed"
Documents are NOT cascaded.
Affected type and template uuids are stored in the activity log payload
so restore_category/1 restores only what this cascade trashed — types
the user had already trashed manually stay trashed.
@spec trash_type( PhoenixKitDocumentCreator.Schemas.Type.t(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Type.t()} | {:error, term()}
Soft-deletes a type by setting its status to "deleted".
Cascades to templates whose type_uuid points at this type. Affected
template uuids are stored in the activity log.
@spec type_options(Ecto.UUID.t() | nil, String.t() | nil) :: [ {String.t(), Ecto.UUID.t() | nil} ]
Returns [{label, value}] for all active types within a category, ordered
by position, preceded by a {"No type", nil} empty option.
Pass nil as category_uuid (or when no category is selected) to get
only the empty option. locale selects the translated label via
localized_name/2.
Suitable for options_for_select/2.
@spec update_category( PhoenixKitDocumentCreator.Schemas.Category.t(), map(), keyword() ) :: {:ok, PhoenixKitDocumentCreator.Schemas.Category.t()} | {:error, Ecto.Changeset.t(PhoenixKitDocumentCreator.Schemas.Category.t())}
Updates a category with the given attributes.
@spec update_type(PhoenixKitDocumentCreator.Schemas.Type.t(), map(), keyword()) :: {:ok, PhoenixKitDocumentCreator.Schemas.Type.t()} | {:error, Ecto.Changeset.t(PhoenixKitDocumentCreator.Schemas.Type.t())}
Updates a type with the given attributes.