Current section
Files
Jump to
Current section
Files
lib/usher.ex
defmodule Usher do
@moduledoc """
Usher is a web framework-agnostic invitation link management library for
any Elixir application with Ecto.
"""
alias Usher.Config
alias Usher.Invitation
alias Usher.InvitationUsage
alias Usher.Invitations.CreateInvitation
alias Usher.Invitations.InvitationUsageQuery
@type entity_id :: String.t()
@type invitation_usages_by_unique_entity :: list({entity_id(), map()})
@doc """
Creates a new invitation with a token and default expiration datetime.
## Attributes
* `:name` - Name for the invitation
* `:expires_at` - Custom expiration datetime (overrides default)
* `:token` - Custom token (overrides generated token)
## Options
* `:require_name` - Whether to require the name field (defaults to false)
## Examples
iex> Usher.create_invitation()
{:ok, %Usher.Invitation{token: "abc123...", expires_at: ~U[...]}}
iex> Usher.create_invitation(%{name: "Welcome Team"})
{:ok, %Usher.Invitation{name: "Welcome Team"}}
iex> Usher.create_invitation(%{}, require_name: true)
{:error, %Ecto.Changeset{errors: [name: {"can't be blank", _}]}}
"""
def create_invitation(attrs \\ %{}, opts \\ []) do
CreateInvitation.call(attrs, opts)
end
@doc """
Retrieves all invitations.
## Examples
iex> Usher.list_invitations()
[%Usher.Invitation{}, ...]
"""
def list_invitations do
Config.repo().all(Invitation)
end
@doc """
Gets a single invitation by ID. Raises if not found.
## Examples
iex> Usher.get_invitation!(id)
%Usher.Invitation{}
iex> Usher.get_invitation!("nonexistent")
** (Ecto.NoResultsError)
"""
def get_invitation!(id) do
Config.repo().get!(Invitation, id)
end
@doc """
Gets an invitation by token.
## Examples
iex> Usher.get_invitation_by_token("valid_token")
%Usher.Invitation{}
iex> Usher.get_invitation_by_token("invalid")
nil
"""
def get_invitation_by_token(token) when is_binary(token) do
case Config.repo().get_by(Invitation, token: token) do
%Invitation{} = invitation -> {:ok, invitation}
nil -> {:error, :not_found}
end
end
@doc """
Validates an invitation token exists and returns the invitation if valid.
Returns `{:ok, invitation}` if the token exists and hasn't expired.
Returns `{:error, reason}` if the token is invalid or expired.
## Examples
iex> Usher.validate_invitation_token("valid_token")
{:ok, %Usher.Invitation{}}
iex> Usher.validate_invitation_token("expired_token")
{:error, :invitation_expired}
iex> Usher.validate_invitation_token("invalid_token")
{:error, :invalid_token}
"""
def validate_invitation_token(token) do
case get_invitation_by_token(token) do
{:ok, invitation} ->
if DateTime.compare(invitation.expires_at, DateTime.utc_now()) == :gt do
{:ok, invitation}
else
{:error, :invitation_expired}
end
{:error, :not_found} ->
{:error, :invalid_token}
end
end
@doc """
Deletes an invitation.
## Examples
iex> Usher.delete_invitation(invitation)
{:ok, %Usher.Invitation{}}
iex> Usher.delete_invitation(bad_invitation)
{:error, %Ecto.Changeset{}}
"""
def delete_invitation(%Invitation{} = invitation) do
Config.repo().delete(invitation)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking invitation changes.
## Options
* `:require_name` - Whether to require the name field (defaults to false)
## Examples
iex> Usher.change_invitation(invitation)
%Ecto.Changeset{data: %Usher.Invitation{}}
iex> Usher.change_invitation(invitation, %{name: "Test"})
%Ecto.Changeset{data: %Usher.Invitation{}}
iex> Usher.change_invitation(invitation, %{}, require_name: true)
%Ecto.Changeset{data: %Usher.Invitation{}, errors: [name: {"can't be blank", _}]}
"""
def change_invitation(%Invitation{} = invitation, attrs \\ %{}, opts \\ []) do
Invitation.changeset(invitation, attrs, opts)
end
@doc """
Builds an invitation URL for the given token and base URL.
## Examples
iex> Usher.invitation_url("abc123", "https://example.com/signup")
"https://example.com/signup?invitation_token=abc123"
"""
def invitation_url(token, base_url) do
uri = URI.parse(base_url)
query = URI.encode_query([{"invitation_token", token}])
%{uri | query: query} |> URI.to_string()
end
# Entity Usage Tracking
@doc """
Records an entity's usage of an invitation.
This provides flexible tracking of how invitations are used. You can track
different actions (like :visited, :registered, :activated) and different
entity types (like :user, :company, :device).
## Parameters
* `invitation_or_token` - An `%Invitation{}` struct or invitation token string
* `entity_type` - String describing the type of entity (e.g., :user, :company, :device)
* `entity_id` - String ID of the entity
* `action` - String describing the action (e.g., :visited, :registered, :activated)
* `metadata` - Optional map of additional data (e.g., user agent, IP, custom fields)
## Examples
# Track a user visiting signup page
{:ok, usage} = Usher.track_invitation_usage(
"abc123",
:user,
"user_123",
:visited,
%{ip: "192.168.1.1", user_agent: "Mozilla/5.0..."}
)
# Track a company registration
{:ok, usage} = Usher.track_invitation_usage(
invitation,
:company,
"company_456",
:registered,
%{plan: "premium", source: "email_campaign"}
)
# Tracking without metadata
{:ok, usage} = Usher.track_invitation_usage("abc123", :user, "789", :activated)
"""
@spec track_invitation_usage(
Invitation.t() | String.t(),
atom(),
String.t(),
atom(),
map()
) ::
{:ok, InvitationUsage.t()}
| {:error, Ecto.Changeset.t()}
| {:error, :invitation_not_found}
def track_invitation_usage(invitation_or_token, entity_type, entity_id, action, metadata \\ %{})
def track_invitation_usage(%Invitation{} = invitation, entity_type, entity_id, action, metadata) do
attrs = %{
invitation_id: invitation.id,
entity_type: entity_type,
entity_id: entity_id,
action: action,
metadata: metadata
}
%InvitationUsage{}
|> InvitationUsage.changeset(attrs)
|> Config.repo().insert()
end
def track_invitation_usage(token, entity_type, entity_id, action, metadata)
when is_binary(token) do
case get_invitation_by_token(token) do
{:ok, invitation} ->
track_invitation_usage(invitation, entity_type, entity_id, action, metadata)
{:error, :not_found} ->
{:error, :invitation_not_found}
end
end
@doc """
Gets all usage records for an invitation.
## Options
* `:entity_type` - Filter by entity type
* `:entity_id` - Filter by entity ID
* `:action` - Filter by action
* `:limit` - Limit number of results
## Examples
# Get all usages for an invitation
usages = Usher.list_invitation_usages(invitation)
# Get only user registrations
usages = Usher.list_invitation_usages(invitation, entity_type: :user, action: :registered)
"""
@spec list_invitation_usages(Invitation.t(), keyword()) :: [InvitationUsage.t()]
def list_invitation_usages(%Invitation{} = invitation, opts \\ []) do
invitation
|> InvitationUsageQuery.list_query(opts)
|> Config.repo().all()
end
@doc """
Gets all usage records for an invitiation, grouped by unique entity IDs.
## Options
* `:entity_type` - Filter by entity type, useful for getting unique usages of a specific entity type
* `:entity_id` - Filter by entity ID, useful for getting unique usages of a specific entity
* `:action` - Filter by action, useful for getting unique usages of a specific action
* `:limit` - Limit number of results
## Examples
# All unique entities that used the invitation
unique_entities = Usher.list_invitation_usages_by_unique_entity(invitation)
# All entities of a specific type that used the invitation
unique_users = Usher.list_invitation_usages_by_unique_entity(invitation, entity_type: :user)
# All entities that took a specific action with the invitation
unique_registrations = Usher.list_invitation_usages_by_unique_entity(
invitation,
action: :registered
)
# A specific entity and the actions they took with the invitation
unique_entity_actions = Usher.list_invitation_usages_by_unique_entity(
invitation,
entity_id: "123"
)
"""
@spec list_invitation_usages_by_unique_entity(Invitation.t(), keyword()) :: [
{String.t(), [invitation_usages_by_unique_entity()]}
]
def list_invitation_usages_by_unique_entity(%Invitation{} = invitation, opts \\ []) do
invitation
|> InvitationUsageQuery.unique_entities_query(opts)
|> Config.repo().all()
end
@doc """
Checks if a specific entity has performed an action on an invitation.
## Examples
# Check if user 123 has registered
if Usher.entity_used_invitation?(invitation, "user", "123", "registered") do
# Entity has already registered
end
# Check if entity has any usage
if Usher.entity_used_invitation?(invitation, "user", "123") do
# Entity has used this invitation for any action
end
"""
def entity_used_invitation?(%Invitation{} = invitation, entity_type, entity_id, action \\ nil) do
invitation
|> InvitationUsageQuery.entity_exists_query(entity_type, entity_id, action)
|> Config.repo().one()
end
end