PhoenixKitLocations.Spaces (PhoenixKitLocations v0.3.0)

Copy Markdown View Source

Context for nested spaces under a Location — rooms, floors, zones, etc. forming a per-location tree.

Same-Location parent invariant

A space's parent_uuid (when set) must reference another space in the same Location. The DB doesn't enforce this directly — a composite FK on (parent_uuid, location_uuid) would, but it's heavier than the consumer surface justifies. We guard at the context boundary instead: create_space/2 and update_space/3 reject any cross-location parent with {:error, :parent_in_other_location}.

Cycle prevention

Direct self-loop is caught by the schema changeset. Indirect cycles (A → B → A) are blocked here in validate_no_cycle/3 before any parent_uuid change is persisted. Walk-up depth-limited to 64 hops — generous for any realistic building hierarchy.

Activity logging

Mutating functions accept opts \ [] and forward :actor_uuid for the activity log. Guarded with Code.ensure_loaded?(PhoenixKit.Activity) and rescued so logging never crashes the mutation.

Parity with Locations:

  • {:ok, space} — logs with space metadata, same as Locations.
  • {:error, %Ecto.Changeset{}} — logs a db_pending: true audit row, same as Locations.
  • {:error, atom} (:cycle, :parent_in_other_location, :parent_not_found, :location_not_found) — not logged: these rejections carry no changeset or resource UUID to attach to, so no partial audit row is written.

Summary

Functions

Builds an empty changeset (for :new forms).

Counts every descendant of space_uuid — children, grandchildren, and so on — not including the space itself. 0 for a leaf, and 0 for an unknown uuid (rather than raising) so callers don't need a defensive existence check first.

Creates a new space. Rejects parents that live in a different Location with {:error, :parent_in_other_location}.

Hard-deletes a space. Children CASCADE via the DB FK — the entire subtree is removed. The activity log records the delete of the named root; children deletes aren't individually logged (would be noisy on deep trees).

Full breadcrumb path for a Space, root Location through the Space itself: "Location / Floor / Zone / Shelf". nil when the space (or its Location) can't be found.

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

All spaces for a Location, ordered by (parent_uuid, position). Returns a flat list; use list_tree/1 for a nested shape.

Nested tree of spaces for a Location. Each node carries a :children key as a list (empty for leaves). Root-level nodes have parent_uuid == nil.

Reorders a sibling group under a single (location, parent) — accepts the full ordered list of sibling UUIDs and rewrites their position to match. Runs in a transaction; returns {:ok, :reordered} or {:error, reason}.

Updates an existing space. Re-parenting is allowed but rejected if the new parent lives in another Location, or if the change would create a cycle.

Types

opts()

@type opts() :: keyword()

uuid()

@type uuid() :: String.t()

Functions

change_space(space, attrs \\ %{})

Builds an empty changeset (for :new forms).

count_descendants(space_uuid)

@spec count_descendants(uuid()) :: non_neg_integer()

Counts every descendant of space_uuid — children, grandchildren, and so on — not including the space itself. 0 for a leaf, and 0 for an unknown uuid (rather than raising) so callers don't need a defensive existence check first.

Backs LocationStructureLive's delete-confirmation modal: before showing "Delete \"X\" and its N descendants?", the caller needs the true blast radius of a hard delete (children CASCADE — see delete_space/2).

create_space(attrs, opts \\ [])

@spec create_space(map(), opts()) ::
  {:ok, PhoenixKitLocations.Schemas.Space.t()}
  | {:error,
     Ecto.Changeset.t()
     | :parent_in_other_location
     | :parent_not_found
     | :location_not_found}

Creates a new space. Rejects parents that live in a different Location with {:error, :parent_in_other_location}.

When attrs doesn't include an explicit position, the new space is appended to the end of its (location_uuid, parent_uuid) sibling group — max(position) + 1, or 0 for the first child. Without this, every space created through the "Add space" form (which never sends position) would sit at the schema default of 0 and jump to the front of its siblings the next time anything reorders that group. An explicit position in attrs — used throughout the test suite to pre-seed sibling order — is always honored as-is.

delete_space(space, opts \\ [])

Hard-deletes a space. Children CASCADE via the DB FK — the entire subtree is removed. The activity log records the delete of the named root; children deletes aren't individually logged (would be noisy on deep trees).

full_path(space_uuid, opts \\ [])

@spec full_path(uuid(), opts()) :: String.t() | nil

Full breadcrumb path for a Space, root Location through the Space itself: "Location / Floor / Zone / Shelf". nil when the space (or its Location) can't be found.

opts[:locale] — when given, each segment's name resolves through PhoenixKit.Utils.Multilang.get_language_data/2 for that language (falling back to the primary-language column when no translation override exists). Omitted (or nil) uses the primary-language column directly for every segment — no data JSONB read at all.

get_space(uuid)

@spec get_space(uuid()) :: PhoenixKitLocations.Schemas.Space.t() | nil

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

list_for_location(location_uuid)

@spec list_for_location(uuid()) :: [PhoenixKitLocations.Schemas.Space.t()]

All spaces for a Location, ordered by (parent_uuid, position). Returns a flat list; use list_tree/1 for a nested shape.

list_tree(location_uuid)

@spec list_tree(uuid()) :: [map()]

Nested tree of spaces for a Location. Each node carries a :children key as a list (empty for leaves). Root-level nodes have parent_uuid == nil.

Single DB read — the tree is assembled in memory from the flat list.

reorder_siblings(location_uuid, parent_uuid, ordered_uuids, opts \\ [])

@spec reorder_siblings(uuid(), uuid() | nil, [uuid()], opts()) ::
  {:ok, :reordered} | {:error, term()}

Reorders a sibling group under a single (location, parent) — accepts the full ordered list of sibling UUIDs and rewrites their position to match. Runs in a transaction; returns {:ok, :reordered} or {:error, reason}.

update_space(space, attrs, opts \\ [])

@spec update_space(PhoenixKitLocations.Schemas.Space.t(), map(), opts()) ::
  {:ok, PhoenixKitLocations.Schemas.Space.t()}
  | {:error,
     Ecto.Changeset.t()
     | :parent_in_other_location
     | :parent_not_found
     | :location_not_found
     | :cycle}

Updates an existing space. Re-parenting is allowed but rejected if the new parent lives in another Location, or if the change would create a cycle.