# `Sigra.Organizations`
[🔗](https://github.com/sztheory/sigra/blob/v1.20.0/lib/sigra/organizations.ex#L1)

Context module for organization CRUD operations, membership management,
and safety guards.

This module implements the library-first architecture (D-01): all
security-critical logic lives here and is updated via `mix deps.update`.
The generated wrapper module uses `use Sigra.Organizations` to inject
thin delegators and overridable hook callbacks.

## Usage

In your generated organizations module:

    defmodule MyApp.Organizations do
      use Sigra.Organizations,
        repo: MyApp.Repo,
        schemas: [
          organization: MyApp.Accounts.Organization,
          membership: MyApp.Accounts.OrganizationMembership,
          invitation: MyApp.Accounts.OrganizationInvitation,
          user: MyApp.Accounts.User,
          scope: MyApp.Accounts.Scope
        ]
    end

## Configuration

See `__config_schema__/0` for the full NimbleOptions schema.

# `add_member`

```elixir
@spec add_member(map(), map(), struct(), struct(), atom()) ::
  {:ok, struct()} | {:error, term()}
```

Adds a user as a member of an organization with the given role.

Runs `before_add_member` hook before insertion. Returns
`{:ok, membership}` or `{:error, reason}`.

# `add_member_multi`
*since 0.4.0* 

```elixir
@spec add_member_multi(
  map(),
  map(),
  struct(),
  struct() | {:changes_key, atom()},
  atom()
) ::
  Ecto.Multi.t()
```

Pure `Ecto.Multi` builder for adding a user to an organization.

Returns a multi with steps:

  * `:add_member_resolve_user` — resolves the user reference
  * `:membership` — inserts the membership changeset
  * audit step (when `:audit_schema` is configured)

Accepts `user_ref` as either a `%User{}` struct or a
`{:changes_key, atom}` tuple referencing a prior step in a composed
multi. The `{:changes_key, _}` shape exists so this builder can be
composed via `Ecto.Multi.append/2` with
`Sigra.Auth.register_user_multi/2` inside
`Sigra.Organizations.Invitations.accept_with_signup/3` (Phase 17 D-07).

Makes ZERO Repo calls — construction is pure.

## Example — direct user

    config
    |> Sigra.Organizations.add_member_multi(scope, org, user, :member)
    |> config.repo.transact()

## Example — composed with register_user_multi/2

    register_multi =
      Sigra.Auth.register_user_multi(attrs, changeset_fn: &User.registration_changeset/1)

    member_multi =
      Sigra.Organizations.add_member_multi(
        config,
        scope,
        org,
        {:changes_key, :user},
        :member
      )

    register_multi
    |> Ecto.Multi.append(member_multi)
    |> config.repo.transact()

# `change_role`

```elixir
@spec change_role(map(), map(), struct(), atom()) ::
  {:ok, struct()} | {:error, term()}
```

Changes a membership's role.

If demoting from the owner role, runs the last-owner guard. Returns
`{:ok, membership}` or `{:error, :last_owner}` / `{:error, changeset}`.

# `count_members`

```elixir
@spec count_members(map(), map()) :: non_neg_integer()
```

Returns the count of memberships in the current active organization.

Unaffected by `:limit` / `:offset` options passed to
`list_members_with_activity/3`.

# `create_organization`

```elixir
@spec create_organization(map(), map(), map()) :: {:ok, struct()} | {:error, term()}
```

Creates an organization and its initial owner membership atomically.

The creating user (from `scope.user`) becomes the owner. Returns
`{:ok, organization}` on success or `{:error, changeset}` on failure.

# `fetch_organization`

```elixir
@spec fetch_organization(map(), binary()) :: {:ok, struct()} | {:error, :not_found}
```

Fetches a non-deleted organization by id without raising.

Added in Phase 14 for the scope-hydration path, which must fail-closed on
stale session pointers rather than propagating `Ecto.NoResultsError` into
the request pipeline (PITFALLS O-6).

Returns `{:ok, org}` or `{:error, :not_found}`. Soft-deleted rows
(`deleted_at != nil`) are treated as not found.

# `get_active_slug_alias`

```elixir
@spec get_active_slug_alias(map(), binary()) :: struct() | nil
```

Fetches a non-expired organization slug alias row for the given old
slug. Used by `Sigra.Plug.LoadOrganizationFromSlug` to follow 7-day
redirects from renamed-org URLs to the canonical slug.

Returns the alias row or `nil`. Soft-expired rows (expires_at <= now)
are treated as non-existent.

# `get_membership`

```elixir
@spec get_membership(map(), struct(), struct()) :: struct() | nil
```

Gets a membership for a user in an organization.

Returns `nil` if the user is not a member.

# `get_organization!`

```elixir
@spec get_organization!(map(), binary()) :: struct()
```

Gets a non-deleted organization by ID.

Raises `Ecto.NoResultsError` if not found or if soft-deleted.

# `get_organization_by_slug`

```elixir
@spec get_organization_by_slug(map(), String.t()) :: struct() | nil
```

Gets a non-deleted organization by slug.

Returns `nil` if not found or if soft-deleted.

# `list_members_with_activity`

```elixir
@spec list_members_with_activity(map(), map(), keyword()) :: [
  {struct(), DateTime.t() | nil}
]
```

Lists memberships for the active organization with the user's most
recent session activity timestamp for that org (Phase 16 D-14, CD-06).

Returns a list of `{membership, last_active_at | nil}` tuples, sorted
by membership inserted_at descending. The `last_active_at` comes from
a LATERAL subquery against `user_sessions` scoped to the current org
(not a cross-org high-water mark).

## Options

  * `:limit` — default 100
  * `:offset` — default 0

Raises `ArgumentError` if `scope.active_organization` is nil (source
of O-1 cross-tenant leak protection).

# `list_organizations_for_user`

```elixir
@spec list_organizations_for_user(
  map(),
  struct()
) :: [struct()]
```

Lists non-deleted organizations for a user via their memberships.

Returns organizations in **unspecified order**. Callers that need a stable
UI listing (e.g. an org switcher) should sort the result themselves —
typically by `name` for display or by `inserted_at` for recency. The
library does not impose an order here because the only internal caller
(`select_active_organization/3`) re-sorts by `inserted_at desc` anyway, and
paying the DB sort cost twice is wasted work (IN-02).

# `list_organizations_with_roles_for_user`

```elixir
@spec list_organizations_with_roles_for_user(
  map(),
  struct()
) :: [{struct(), atom()}]
```

Phase 16 Plan 03 variant that returns `{org, role}` tuples instead of bare
orgs, ordered by `membership.inserted_at DESC` (most recently joined first).

Used by:
  * the generated `on_mount :assign_user_organizations` hook, which
    assigns the tuple list as `@user_organizations` for the org switcher
    component to render with role badges
  * `OrganizationsLive.Index` Branch C (picker) to render each
    membership row with its role badge
  * the generated `/organizations/switch` controller to resolve a target
    organization from the current user's memberships (membership-before-
    write authz choke point)

Soft-deleted orgs are filtered via `is_nil(o.deleted_at)` — T-16-03-04.

# `list_pending_invitations_for_user`

```elixir
@spec list_pending_invitations_for_user(
  map(),
  struct()
) :: [struct()]
```

Lists pending invitations for a user (by email, case-insensitive).

Delegates to `Sigra.Organizations.Invitations.list_pending_for_user/2`.
Phase 17 D-14 replaces the Phase 16 stub with the real query.

# `remove_member`

```elixir
@spec remove_member(map(), map(), struct()) :: {:ok, struct()} | {:error, term()}
```

Removes a membership from an organization.

Guarded by the last-owner check: if the membership is the sole owner,
returns `{:error, :last_owner}`. Hard-deletes the membership row (D-11).

# `rename_organization`

```elixir
@spec rename_organization(map(), map(), struct(), map()) ::
  {:ok, struct()} | {:error, Ecto.Changeset.t()}
```

Renames an organization. Only `:name` is updatable via this function;
slug changes go through `update_slug/4` (Phase 16 D-15).

Returns `{:ok, org}` or `{:error, changeset}`.

# `select_active_organization`

```elixir
@spec select_active_organization(map(), struct(), keyword()) ::
  {:ok, struct()} | {:none, :zero_orgs} | {:multiple, [struct()]}
```

Pure selector that returns the active organization to land a user on.

Called from login (`Sigra.Auth.create_session/4`) and from the stale-pointer
recovery path in `Sigra.Plug.LoadActiveOrganization`. No side effects — no
session writes, no audit, no DB writes beyond the reads required to list
memberships.

## Options

  * `:previous_active_organization_id` (binary_id | nil) — if the user has
    2+ orgs and one matches this pointer, return `{:ok, that_org}` (resume
    semantics). On stale recovery this is passed as `nil` — the stale
    pointer must NOT be resumed.

  * `:strategy` — reserved for v1.2, ignored in v1.1.

## Returns

  * `{:ok, org}` — user has exactly one org, or resume pointer matched.
  * `{:none, :zero_orgs}` — user has no memberships.
  * `{:multiple, orgs}` — 2+ memberships, no resume pointer match. `orgs`
    is sorted by `inserted_at` descending for stable UI ordering (CD-04).

Added in Phase 14 (Plan 14-01, D-11). Covers ORG-SCOPE-06.

# `select_active_organization_with_membership`

```elixir
@spec select_active_organization_with_membership(map(), struct(), keyword()) ::
  {:ok, struct(), struct()} | {:none, :zero_orgs} | {:multiple, [struct()]}
```

Variant of `select_active_organization/3` that also returns the membership
struct on the `{:ok, org}` branches, avoiding a second `get_membership/3`
roundtrip on the stale-pointer recovery path.

Internal to Phase 14+ plug recovery. Prefer `select_active_organization/3`
for callers that don't need the membership struct.

## Returns

  * `{:ok, org, membership}` — user has exactly one org, or resume pointer
    matched.
  * `{:none, :zero_orgs}` — user has no memberships.
  * `{:multiple, orgs}` — 2+ memberships, no resume pointer match.
    Membership is intentionally NOT returned on this branch because the
    caller must still prompt the user; the picker doesn't know which org
    will be chosen.

Added in Phase 14 (WR-03 fix). The single-query implementation joins the
membership table once and reuses the rows for both org listing and
membership resolution.

# `soft_delete_organization`

```elixir
@spec soft_delete_organization(map(), map(), struct(), map()) ::
  {:ok, struct()} | {:error, :invalid_password | Ecto.Changeset.t() | term()}
```

Soft-deletes an organization by setting `deleted_at`.

Requires both the current user's password AND a typed-confirm of the
organization's name (Phase 16 D-11, D-15). Returns:

  * `{:ok, organization}` — success
  * `{:error, :invalid_password}` — password did not match
  * `{:error, %Ecto.Changeset{}}` — confirm_name mismatch or other
    validation failure

The caller is responsible for refreshing sudo state after success
(Phase 16 D-11, moved out of the library in 16-01 because the org
config does not carry the session token). LiveViews ship this in Plan
02 via `Sigra.Auth.confirm_sudo/3`.

Runs `before_delete_organization` hook before the transaction and
`after_delete_organization` after successful commit.

# `update_organization`

```elixir
@spec update_organization(map(), map(), struct(), map()) ::
  {:ok, struct()} | {:error, term()}
```

Updates an organization's attributes.

Returns `{:ok, updated_org}` or `{:error, changeset}`.

# `update_slug`

```elixir
@spec update_slug(map(), map(), struct(), map()) ::
  {:ok, struct()} | {:error, :invalid_password | Ecto.Changeset.t()}
```

Updates an organization's slug under sudo + typed-confirm gates.

Requires:
  * `:slug` — new slug (must match slug_format + non-reserved + unique)
  * `:password` — current user's password (sudo re-verification)
  * `:confirm_slug` — typed-back copy of the CURRENT slug (so users
    cannot slip and rename the wrong org)

On success, atomically:
  1. Updates `org.slug`
  2. Inserts an `OrganizationSlugAlias` row with the previous slug
     and `expires_at = now + 7 days` so the load plug can redirect
     old URLs for the grace window (Phase 16 D-13).
  3. Appends audit event `"organization.slug_change"`.

Returns:
  * `{:ok, org}`
  * `{:error, :invalid_password}`
  * `{:error, %Ecto.Changeset{}}`

---

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