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
]
endConfiguration
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.
Changes a membership's role.
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
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}.
@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_schemais 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()
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}.
@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.
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.
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.
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.
Gets a membership for a user in an organization.
Returns nil if the user is not a member.
Gets a non-deleted organization by ID.
Raises Ecto.NoResultsError if not found or if soft-deleted.
Gets a non-deleted organization by slug.
Returns nil if not found or if soft-deleted.
@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).
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).
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_organizationshook, which assigns the tuple list as@user_organizationsfor the org switcher component to render with role badges OrganizationsLive.IndexBranch C (picker) to render each membership row with its role badge- the generated
/organizations/switchcontroller 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.
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.
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).
@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}.
@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 asnil— 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.orgsis sorted byinserted_atdescending for stable UI ordering (CD-04).
Added in Phase 14 (Plan 14-01, D-11). Covers ORG-SCOPE-06.
@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.
@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.
Updates an organization's attributes.
Returns {:ok, updated_org} or {:error, changeset}.
@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:
- Updates
org.slug - Inserts an
OrganizationSlugAliasrow with the previous slug andexpires_at = now + 7 daysso the load plug can redirect old URLs for the grace window (Phase 16 D-13). - Appends audit event
"organization.slug_change".
Returns:
{:ok, org}{:error, :invalid_password}{:error, %Ecto.Changeset{}}