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

Phase 17 invitation lifecycle: `create/2`, `revoke/3`, `list_pending/2`,
`list_pending_for_user/2`. `accept/3` and `accept_with_signup/3` land
in Plan 17-05.

All security-critical logic (HMAC token generation via
`Sigra.Token.generate_invite_envelope/2`, `Ecto.Multi` atomicity,
dual-key Hammer rate limiting, authorization) lives in this library
module. The generated `OrganizationMembersLive` is a thin event-handler
wrapper that delegates to `use Sigra.Organizations` re-exports.

## Key invariants

- `organization_invitations.hashed_token` is SHA-256 of the raw
  base64 invite token. The raw token **never** touches the DB; it is
  only present in the signed envelope threaded into the accept URL
  (HMAC-bound to email via `Sigra.Token.generate_invite_envelope/2`).
- Pending uniqueness is enforced by the partial-unique index
  `organization_invitations_pending_index` (IS-NULL-only predicate,
  IMMUTABLE-safe per D-03).
- Re-invite on a pending email (D-05) revokes the old row and inserts
  a new row in a single `Ecto.Multi`, preserving the partial-unique
  invariant with zero windows where two rows are pending.
- Rate limiting is dual-key:
  `"sigra:org_invite_create:user:<user_id>"` checked first, then
  `"sigra:org_invite_create:org:<org_id>"`. Fail-open per D-41 is
  handled inside `Sigra.RateLimiters.Hammer`.
- Email delivery happens **after** successful `repo.transact/2`.
  Mailer failures are logged + telemetry-emitted and **never** roll
  back the committed DB row (D-12 discretion).

## Asymmetric error exposure (Pitfall 7)

Creating an invite for an existing member returns `:already_member`
while creating for a user with no account returns `{:ok, _}`. This is
acceptable because the actor is already an admin who can
`list_members/2` and see membership state directly — no new
information is leaked.

## Telemetry events

- `[:sigra, :invitation, :email_sent]` — after successful email send
- `[:sigra, :invitation, :email_delivery_failed]` — after mailer raises
- `[:sigra, :invitation, :email_skipped]` — when `emails_module` is nil

# `accept`

```elixir
@spec accept(map(), String.t(), struct()) ::
  {:ok, %{membership: struct(), invitation: struct()}}
  | {:error, :invalid | :expired | :revoked | :already_accepted | :mismatch}
```

Accept an invitation as an already-signed-in user whose email matches
the invitation email (case-insensitive via citext + `String.downcase/1`
belt-and-suspenders).

Returns:

  * `{:ok, %{membership: _, invitation: _}}` — membership row inserted,
    invitation stamped with `accepted_at` + `accepted_by_id`, audit event
    emitted atomically via `repo.transact/1`.
  * `{:error, :invalid}` — HMAC verify failed, base64 decode failed,
    payload shape wrong, bound_email does not match DB row email, or
    invitation row missing for the looked-up hashed_token. Collapsed
    uniformly to `:invalid` to avoid information leakage.
  * `{:error, :expired}` — invitation envelope or DB `expires_at`
    is in the past.
  * `{:error, :revoked}` — `revoked_at IS NOT NULL`.
  * `{:error, :already_accepted}` — `accepted_at IS NOT NULL` (replay).
  * `{:error, :mismatch}` — `current_user.email != invitation.email`
    (Jetstream #907 / CVE-2026-1529 defense). ZERO DB writes.

All non-`:ok` branches skip audit emission for `organization.invitation.accepted`.

# `accept_with_signup`

```elixir
@spec accept_with_signup(map(), String.t(), map()) ::
  {:ok, %{user: struct(), membership: struct(), invitation: struct()}}
  | {:error,
     :invalid
     | :expired
     | :revoked
     | :already_accepted
     | :email_mismatch
     | Ecto.Changeset.t()}
```

Accept an invitation as an anonymous visitor by atomically registering
a new user, confirming them (HMAC-bound invite acceptance proves email
ownership), inserting a membership, and stamping the invitation.

All four Multi steps run inside a single `repo.transact/1`. Any step
failure rolls back the entire transaction — zero orphan rows across
the user, membership, and invitation tables (Pow #534 regression
invariant).

The caller-supplied `user_params["email"]` is rejected server-side if
it does not match `invitation.email` case-insensitively, even though
the UI locks the field via `disabled + readonly`. The locked email is
also force-overwritten onto the registration params as a
belt-and-suspenders defense against direct POST tampering.

Raises `RuntimeError` when `config.user_registration_changeset_fn` is
nil — the host app's installer must wire this to
`&YourApp.Accounts.User.registration_changeset/1`.

# `create`

```elixir
@spec create(map(), map()) ::
  {:ok, struct()}
  | {:error,
     :rate_limited_user
     | :rate_limited_org
     | :unauthorized
     | :already_member
     | Ecto.Changeset.t()}
```

Create a pending invitation for `email` to join `organization_id` with `role`.

Requires the actor (passed via `attrs.actor`) to carry a membership with
role in `[:owner, :admin]`. Returns:

  * `{:ok, %OrganizationInvitation{} = invitation}` — invitation row
    inserted, with an ephemeral `__encoded_token__` key on the struct
    carrying the signed envelope (useful if the caller wants to thread
    it somewhere beyond the built-in email delivery path).
  * `{:error, :unauthorized}` — actor lacks owner/admin role
  * `{:error, :rate_limited_user}` — per-user Hammer limit exceeded
  * `{:error, :rate_limited_org}` — per-org Hammer limit exceeded
  * `{:error, %Ecto.Changeset{}}` — invitation changeset invalid

Raises `RuntimeError` when `config.secret_key_base` or
`config.url_builder` is nil (Phase 17 runtime requirements).

# `list_pending`

```elixir
@spec list_pending(map(), struct() | integer() | binary()) :: [struct()]
```

List pending invitations for `org` (struct or id). Returns the rows
sorted by `inserted_at` descending with `:invited_by` preloaded.

"Pending" means `accepted_at IS NULL AND revoked_at IS NULL AND
expires_at > now()`. The expires_at filter is applied at query time
so expired-but-not-yet-cleaned-up rows don't surface to the admin UI.

# `list_pending_for_user`

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

List pending invitations for a user (by email, case-insensitive via
`citext`). Returns rows with `:organization` and `:invited_by`
preloaded, sorted by `inserted_at` descending.

# `load_for_view`

```elixir
@spec load_for_view(map(), String.t(), struct() | nil) ::
  {:signup, struct(), struct(), struct()}
  | {:accept, struct(), struct(), struct(), struct()}
  | {:mismatch, struct(), struct()}
  | {:invalid, :invalid}
  | {:expired, struct() | nil}
  | {:revoked, struct() | nil}
  | {:already_accepted, struct() | nil, boolean()}
```

Load an invitation and classify it into a render-branch tuple for
`InvitationAcceptLive` (Plan 17-07, D-06).

Does NOT run any Multi — the caller invokes `accept/3` or
`accept_with_signup/3` separately on user action. This function
performs the HMAC verify + DB lookup + optional org/inviter load and
returns a tuple the LV assigns directly to `:branch` + related assigns.

The `current_user` argument is `nil` for anonymous visitors (signup
branch) or a user struct for signed-in visitors (accept or mismatch
branch depending on email match).

Returned tuples:

  * `{:signup, invitation, org, inviter}` — anonymous visitor, token valid + pending
  * `{:accept, invitation, org, inviter, current_user}` — signed-in
    user's email matches invitation email
  * `{:mismatch, invitation, current_user}` — signed-in user's email
    does NOT match invitation email (Jetstream #907 mismatch branch).
    `org` and `inviter` are deliberately not returned — the mismatch
    branch does not render them.
  * `{:invalid, :invalid}` — HMAC verify fail, tampered token, garbage
    base64, or invitation row missing. Uniformly `:invalid` for zero
    info leakage.
  * `{:expired, invitation_or_nil}` — envelope age > TTL or DB
    `expires_at <= now()`. The row is reloaded without the pending
    guard so the view can display inviter context.
  * `{:revoked, invitation_or_nil}` — DB `revoked_at IS NOT NULL`.
  * `{:already_accepted, invitation_or_nil, maybe_member?}` — DB
    `accepted_at IS NOT NULL` (replay). The boolean is currently
    always `false` — future work may use it to auto-redirect
    members to their org dashboard.

# `revoke`

```elixir
@spec revoke(map(), integer() | binary(), map()) ::
  {:ok, struct()} | {:error, :not_pending | :unauthorized | :not_found}
```

Revoke a pending invitation.

Requires the actor to carry a membership with role in
`[:owner, :admin]`. Already-accepted or already-revoked
invitations return `{:error, :not_pending}` — the DB row is untouched.
Missing invitation id → `{:error, :not_found}`. The lookup is scoped
to `actor_scope.active_organization.id`; cross-tenant ids are
collapsed to `{:error, :not_found}` to prevent enumeration.

---

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