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 published templates per group, keyed by type uuid.
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_published_templates_by_type([Ecto.UUID.t()]) :: %{ required(Ecto.UUID.t()) => pos_integer() }
Counts published templates per group, keyed by type uuid.
Counted through the phoenix_kit_doc_template_taxonomy memberships:
published templates filed under the type's current category, which is
what PhoenixKitDocumentCreator.Documents.list_templates_for_category/1
returns for that category. So a template filed under several categories
counts once in each category's group, and trashed, lost or unfiled
templates are not counted. A membership left under the old category when
a type moves to another one is not counted: the new category's listing
does not return it, and the old category no longer shows the type (its
listing still returns the row, which this count deliberately ignores).
One query for the whole list; a type with no published template is
absent from the map.
@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.