# `Ithibati.Identity.Invitations`
[🔗](https://github.com/oliverandrich/ithibati/blob/v0.6.2/lib/ithibati/identity/invitations.ex#L1)

Finds, lists and withdraws pending invitations, and composes their acceptance into
application transactions.

The application owns the invitation schema and any membership or permission it grants.
Ithibati checks the token, expiry and acceptance state, and binds the invitation's identifier
to the account created in the transaction.

For acceptance outside a WebAuthn ceremony:

    alias Ecto.Multi
    alias MyApp.Accounts.User

    Multi.new()
    |> Multi.insert(:account, User.changeset(%User{}, account_attrs(invitation)))
    |> Ithibati.Identity.Invitations.accept(invitation)
    |> Ithibati.Identity.Grant.with_key_and_codes(key_attrs)
    |> MyApp.Repo.transaction()

Insert application steps such as membership creation before the grant. In a WebAuthn handler,
build the account from the subject approved at the challenge step, rather than rereading its
identifier from the final request's invitation. See [Invitations](invitations.md).

# `accept`

Appends an `:invitation` acceptance step and returns the multi.

Pass an invitation struct, not `nil`; handle a failed `fetch/1` lookup before composing this
step. The `:account` option names an earlier account step and defaults to `:account`.

When the account step exists, acceptance checks that its identifier matches the invitation.
A mismatch fails with `:identifier_mismatch`. If no account step exists, that comparison is
skipped, allowing acceptance to be composed independently.

The write rechecks expiry and acceptance state. If the invitation is no longer available,
the step fails with `:invalid_invitation`. Concurrent attempts cannot both accept the same row.

On success, `:invitation` contains the updated invitation. On failure, `Repo.transaction/1`
returns `{:error, :invitation, reason, changes_so_far}` and rolls back the transaction. Place
this step before `Ithibati.Identity.Grant.with_key_and_codes/3`.

# `account_attrs`

Returns a map containing the invitation's identifier under its declared field name.

Use it to initialize an account outside a WebAuthn ceremony. During registration, use the
subject supplied to `c:Ithibati.Web.Handler.register/4`, which was approved with the challenge.

# `delete_expired`

Deletes expired, unaccepted invitations and returns the number of rows removed.

# `expired`

Returns expired invitations that have not been accepted.

Ithibati schedules no cleanup. Use this list to inspect pending deletions, or call
`delete_expired/0` to remove them. Expired invitations are refused by `fetch/1` regardless of
whether their rows remain in the database.

# `fetch`

Returns the pending invitation for a plaintext token, or `nil`.

Pending means unaccepted and unexpired. Unknown, accepted and expired tokens all return `nil`,
as do non-string inputs. This lookup does not reserve the invitation; `accept/3` rechecks its
state when writing.

# `pending`

Returns every pending invitation. See `pending_query/0` to narrow the list first.

# `pending_query`

Returns the query that finds pending invitations, for the application to narrow.

Pending means the same thing it means to `fetch/1`: unaccepted and unexpired. The application
owns the table and whatever columns it added, so the list it wants is rarely all of them — it
scopes, orders and preloads on top of this. Taking the predicate from here rather than writing
it again is what keeps a page from offering a link that no longer opens anything, and keeps
whatever the page can show to exactly what `withdraw/1` will take back.

The query holds the moment it was built, not the moment it runs. Build it where you run it. A
query kept across requests goes on listing invitations that have since expired, and each one
offers a link that opens nothing. Ithibati asks no database for its own clock here, because
the three it supports do not agree on what that answer means.

# `withdraw`

Takes back an invitation nobody has accepted, so its link opens nothing.

Returns `{:ok, invitation}`, or `{:error, :already_accepted}` when the row was accepted or is
no longer there. The state is rechecked inside the delete rather than read beforehand: an
invitation accepted between the reading and the writing would otherwise be withdrawn along
with the account it just made.

An expired invitation can still be withdrawn. It opens nothing either way, and leaving the row
for `delete_expired/0` is a separate decision.

---

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