Sigra.Organizations (Sigra v1.20.0)

Copy Markdown View Source

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.

Summary

Functions

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

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

Returns the count of memberships in the current active organization.

Creates an organization and its initial owner membership atomically.

Fetches a non-deleted organization by id without raising.

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.

Gets a membership for a user in an organization.

Gets a non-deleted organization by ID.

Gets a non-deleted organization by slug.

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

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

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

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

Removes a membership from an organization.

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

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

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.

Soft-deletes an organization by setting deleted_at.

Updates an organization's attributes.

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

Functions

add_member(config, scope, org, user, role)

@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(config, scope, org, user_ref, role)

(since 0.4.0)
@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(config, scope, membership, new_role)

@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(config, scope)

@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(config, scope, attrs)

@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(config, id)

@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(config, slug)

@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(config, user, org)

@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!(config, id)

@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(config, slug)

@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(config, scope, opts \\ [])

@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(config, user)

@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(config, user)

@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(config, user)

@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(config, scope, membership)

@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(config, scope, org, params)

@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(config, user, opts \\ [])

@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(config, user, opts \\ [])

@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(config, scope, org, params)

@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(config, scope, org, attrs)

@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(config, scope, org, params)

@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{}}