# `PhoenixKitLocations.Spaces`
[🔗](https://github.com/BeamLabEU/phoenix_kit_locations/blob/0.4.2/lib/phoenix_kit_locations/spaces.ex#L1)

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.

# `opts`

```elixir
@type opts() :: keyword()
```

# `uuid`

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

# `change_space`

```elixir
@spec change_space(PhoenixKitLocations.Schemas.Space.t(), map()) :: Ecto.Changeset.t()
```

Builds an empty changeset (for `:new` forms).

# `count_descendants`

```elixir
@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`

```elixir
@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`

```elixir
@spec delete_space(PhoenixKitLocations.Schemas.Space.t(), opts()) ::
  {:ok, PhoenixKitLocations.Schemas.Space.t()} | {:error, Ecto.Changeset.t()}
```

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`

```elixir
@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`

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

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

# `list_for_location`

```elixir
@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`

```elixir
@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`

```elixir
@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`

```elixir
@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.

---

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