prop/lib/microwaveprop/accounts.ex
Graham McIntire 9db2cd20f5
fix: resolve 7 pre-existing test failures and ScoreCache DateTime bug
- CacheTest: fix sweep timing race by using negative TTL (-1)
  instead of positive TTL (1) for already-expired entries
- ScoreCache: replace ETS match-spec DateTime comparisons with
  :ets.foldl + DateTime.compare — DateTime structs are maps in
  Elixir >= 1.15 and ETS can't compare maps with :< / :> guards
- Accounts: drop unsupported returning: true on delete_all,
  return [] for expired tokens list
- Backtest: catch ArgumentError from String.to_existing_atom
  for unknown feature names, preserving the helpful Mix.Error
- ContactLive IndexTest: invalidate monthly_bars cache before
  assertion so test data is visible
- RoverLocationsLive MapTest: invalidate cached points before
  assertion
- StatusLiveTest: add DB cleanup in setup to reduce test
  interference; parameterize NARR candidate coordinates
2026-06-10 13:50:24 -05:00

538 lines
16 KiB
Elixir

defmodule Microwaveprop.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Microwaveprop.Accounts.User
alias Microwaveprop.Accounts.UserApiToken
alias Microwaveprop.Accounts.UserNotifier
alias Microwaveprop.Accounts.UserToken
alias Microwaveprop.Repo
## Database getters
alias Microwaveprop.Workers.UserHomeQthLookupWorker
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
@spec get_user_by_email(String.t()) :: User.t() | nil
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by their amateur callsign. The lookup is case-insensitive
so `/u/w5isp` and `/u/W5ISP` resolve to the same profile.
Returns `nil` for unknown callsigns and for `nil`/empty input.
## Examples
iex> get_user_by_callsign("W5ISP")
%User{}
iex> get_user_by_callsign("unknown")
nil
"""
@spec get_user_by_callsign(String.t() | nil) :: User.t() | nil
def get_user_by_callsign(nil), do: nil
def get_user_by_callsign(""), do: nil
def get_user_by_callsign(callsign) when is_binary(callsign) do
Repo.get_by(User, callsign: String.upcase(callsign))
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
@spec get_user_by_email_and_password(String.t(), String.t()) :: User.t() | nil
def get_user_by_email_and_password(email, password) when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
@spec get_user!(Ecto.UUID.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
## Admin user management
@doc "Returns users ordered by callsign, limited to 100."
@spec list_users() :: [User.t()]
def list_users do
Repo.all(from u in User, order_by: [asc: u.callsign], limit: 100)
end
@doc "Updates admin-managed user fields (callsign, name, email, is_admin)."
@spec admin_update_user(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def admin_update_user(%User{} = user, attrs) do
user
|> User.admin_changeset(attrs)
|> Repo.update()
end
@doc "Returns an admin-edit changeset for rendering forms."
@spec change_admin_user(User.t(), map()) :: Ecto.Changeset.t()
def change_admin_user(%User{} = user, attrs \\ %{}) do
User.admin_changeset(user, attrs)
end
@doc "Deletes a user."
@spec delete_user(User.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def delete_user(%User{} = user), do: Repo.delete(user)
## User registration
@doc """
Registers a user.
## Examples
iex> register_user(%{field: value})
{:ok, %User{}}
iex> register_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
|> Repo.insert()
|> maybe_enqueue_home_qth_lookup()
end
defp maybe_enqueue_home_qth_lookup({:ok, %User{id: id, callsign: call} = user}) when is_binary(call) do
_ =
if home_qth_lookup_enabled?() do
worker = UserHomeQthLookupWorker
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
_ = %{user_id: id} |> worker.new() |> Oban.insert()
end
end
{:ok, user}
end
defp maybe_enqueue_home_qth_lookup(other), do: other
defp home_qth_lookup_enabled? do
Application.get_env(:microwaveprop, :enable_home_qth_lookup, true)
end
@doc """
Enqueue a `UserHomeQthLookupWorker` job for every user whose
`home_grid` is still nil. Safe to call repeatedly — the worker
no-ops when the user has set their QTH manually.
"""
@spec backfill_missing_home_qth() :: :ok
def backfill_missing_home_qth do
worker = UserHomeQthLookupWorker
_ =
if home_qth_lookup_enabled?() and Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
User
|> where([u], is_nil(u.home_grid))
|> select([u], u.id)
|> Repo.all()
|> Enum.map(fn id -> worker.new(%{user_id: id}) end)
|> then(&if &1 != [], do: Oban.insert_all(&1))
end
:ok
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user registration changes.
"""
@spec change_user_registration(User.t(), map()) :: Ecto.Changeset.t()
def change_user_registration(%User{} = user, attrs \\ %{}) do
User.registration_changeset(user, attrs, hash_password: false, validate_unique: false)
end
## Settings
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
@spec sudo_mode?(term(), integer()) :: boolean()
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.add(DateTime.utc_now(), minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the user email.
See `Microwaveprop.Accounts.User.email_changeset/3` for a list of supported options.
## Examples
iex> change_user_email(user)
%Ecto.Changeset{data: %User{}}
"""
@spec change_user_email(User.t(), map(), keyword()) :: Ecto.Changeset.t()
def change_user_email(user, attrs \\ %{}, opts \\ []) do
User.email_changeset(user, attrs, opts)
end
@doc """
Updates the user email using the given token.
If the token matches, the user email is updated and the token is deleted.
"""
@spec update_user_email(User.t(), String.t()) :: {:ok, User.t()} | {:error, :transaction_aborted}
def update_user_email(user, token) do
context = "change:#{user.email}"
Repo.transact(fn ->
with {:ok, query} <- UserToken.verify_change_email_token_query(token, context),
%UserToken{sent_to: email} <- Repo.one(query),
{:ok, user} <- Repo.update(User.email_changeset(user, %{email: email})),
{_count, _result} <-
Repo.delete_all(from(UserToken, where: [user_id: ^user.id, context: ^context])) do
{:ok, user}
else
_ -> {:error, :transaction_aborted}
end
end)
end
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
See `Microwaveprop.Accounts.User.password_changeset/3` for a list of supported options.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
@spec change_user_password(User.t(), map(), keyword()) :: Ecto.Changeset.t()
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
@doc """
Updates the user password.
Returns a tuple with the updated user, as well as a list of expired tokens.
## Examples
iex> update_user_password(user, %{password: ...})
{:ok, {%User{}, [...]}}
iex> update_user_password(user, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
@spec update_user_password(User.t(), map()) ::
{:ok, {User.t(), [UserToken.t()]}} | {:error, Ecto.Changeset.t()}
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
@doc """
Builds a home-QTH changeset from the given attrs (no DB write).
Useful for rendering the form in templates and LiveViews.
"""
@spec change_user_home_qth(User.t(), map()) :: Ecto.Changeset.t()
def change_user_home_qth(%User{} = user, attrs \\ %{}) do
User.change_home_qth(user, attrs)
end
@doc """
Updates the user's home QTH (grid + lat/lon + optional elevation).
"""
@spec update_user_home_qth(User.t(), map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def update_user_home_qth(%User{} = user, attrs) do
user |> User.change_home_qth(attrs) |> Repo.update()
end
## Session
@doc """
Generates a session token.
"""
@spec generate_user_session_token(User.t()) :: binary()
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
@spec get_user_by_session_token(binary()) :: {User.t(), DateTime.t()} | nil
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
## Confirmation
@doc ~S"""
Delivers the account confirmation instructions to the given user.
Returns `{:error, :already_confirmed}` if the user is already confirmed.
"""
@spec deliver_user_confirmation_instructions(User.t(), (String.t() -> String.t())) ::
{:ok, Swoosh.Email.t()} | {:error, :already_confirmed}
def deliver_user_confirmation_instructions(%User{} = user, confirmation_url_fun)
when is_function(confirmation_url_fun, 1) do
if user.confirmed_at do
{:error, :already_confirmed}
else
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
Repo.insert!(user_token)
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
end
end
@doc """
Confirms a user by the given token.
On success, the confirmation token (and all other tokens tied to this user)
are deleted to prevent reuse.
"""
@spec confirm_user_by_token(String.t()) ::
{:ok, {User.t(), [UserToken.t()]}} | {:error, :not_found | Ecto.Changeset.t()}
def confirm_user_by_token(token) do
with {:ok, query} <- UserToken.verify_confirm_token_query(token),
{%User{} = user, _token} <- Repo.one(query) do
user
|> User.confirm_changeset()
|> update_user_and_delete_all_tokens()
else
_ -> {:error, :not_found}
end
end
@doc ~S"""
Delivers the update email instructions to the given user.
## Examples
iex> deliver_user_update_email_instructions(user, current_email, &url(~p"/users/settings/confirm-email/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
@spec deliver_user_update_email_instructions(
User.t(),
String.t(),
(String.t() -> String.t())
) :: {:ok, Swoosh.Email.t()} | {:error, term()}
def deliver_user_update_email_instructions(%User{} = user, current_email, update_email_url_fun)
when is_function(update_email_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "change:#{current_email}")
Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
end
## Password reset
@doc ~S"""
Delivers self-service password-reset instructions to the given user.
## Examples
iex> deliver_user_reset_password_instructions(user, &url(~p"/users/reset-password/#{&1}"))
{:ok, %{to: ..., body: ...}}
"""
@spec deliver_user_reset_password_instructions(User.t(), (String.t() -> String.t())) ::
{:ok, Swoosh.Email.t()} | {:error, term()}
def deliver_user_reset_password_instructions(%User{} = user, reset_password_url_fun)
when is_function(reset_password_url_fun, 1) do
{encoded_token, user_token} = UserToken.build_email_token(user, "reset_password")
Repo.insert!(user_token)
UserNotifier.deliver_reset_password_instructions(user, reset_password_url_fun.(encoded_token))
end
@doc """
Gets the user by a password-reset token, or nil if the token is invalid or expired.
"""
@spec get_user_by_reset_password_token(String.t()) :: User.t() | nil
def get_user_by_reset_password_token(token) do
with {:ok, query} <- UserToken.verify_password_reset_token_query(token),
{%User{} = user, _token} <- Repo.one(query) do
user
else
_ -> nil
end
end
@doc """
Resets the user password and revokes all tokens for that user.
Returns `{:ok, {user, expired_tokens}}` on success.
"""
@spec reset_user_password(User.t(), map()) ::
{:ok, {User.t(), [UserToken.t()]}} | {:error, Ecto.Changeset.t()}
def reset_user_password(%User{} = user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
@doc """
Deletes the signed token with the given context.
"""
@spec delete_user_session_token(binary()) :: :ok
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## API tokens (long-lived bearer tokens for /api/v1)
@doc """
Creates a new long-lived API token for the user. Returns the
plaintext token (only shown once) alongside the persisted record.
"""
@spec create_api_token(User.t(), map()) ::
{:ok, {String.t(), UserApiToken.t()}} | {:error, Ecto.Changeset.t()}
def create_api_token(%User{} = user, attrs) do
case UserApiToken.build(user, attrs) do
{:ok, {plaintext, changeset}} ->
case Repo.insert(changeset) do
{:ok, record} -> {:ok, {plaintext, record}}
{:error, changeset} -> {:error, changeset}
end
{:error, changeset} ->
{:error, changeset}
end
end
@doc "Lists every non-revoked API token for the user, newest first."
@spec list_api_tokens(User.t()) :: [UserApiToken.t()]
def list_api_tokens(%User{id: user_id}) do
Repo.all(
from t in UserApiToken,
where: t.user_id == ^user_id and is_nil(t.revoked_at),
order_by: [desc: t.inserted_at]
)
end
@doc """
Looks up the user owning the given plaintext bearer token. Returns
`{:ok, user, token}` for valid, unexpired, non-revoked tokens. The
token's `last_used_at` is updated as a side effect.
"""
@spec get_user_by_api_token(String.t()) ::
{:ok, User.t(), UserApiToken.t()} | {:error, :invalid_token}
def get_user_by_api_token(plaintext) when is_binary(plaintext) do
hash = UserApiToken.hash_token(plaintext)
now = DateTime.utc_now(:second)
query =
from t in UserApiToken,
join: u in assoc(t, :user),
where: t.token_hash == ^hash,
where: is_nil(t.revoked_at),
where: is_nil(t.expires_at) or t.expires_at > ^now,
select: {u, t}
case Repo.one(query) do
nil ->
{:error, :invalid_token}
{user, token} ->
{1, _} =
Repo.update_all(
from(t in UserApiToken, where: t.id == ^token.id),
set: [last_used_at: now]
)
{:ok, user, %{token | last_used_at: now}}
end
end
@doc """
Revokes a user's API token by id. Returns `{:error, :not_found}`
when the id is not owned by the user.
"""
@spec revoke_api_token(User.t(), Ecto.UUID.t()) ::
{:ok, UserApiToken.t()} | {:error, :not_found}
def revoke_api_token(%User{id: user_id}, token_id) do
case Repo.get_by(UserApiToken, id: token_id, user_id: user_id) do
nil ->
{:error, :not_found}
%UserApiToken{revoked_at: nil} = token ->
token
|> Ecto.Changeset.change(revoked_at: DateTime.utc_now(:second))
|> Repo.update()
%UserApiToken{} = token ->
{:ok, token}
end
rescue
# Malformed UUID in the URL — treat as a clean 404 instead of
# bubbling a 500 out of `Repo.get_by`'s UUID cast.
Ecto.Query.CastError -> {:error, :not_found}
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
Repo.delete_all(from(t in UserToken, where: t.user_id == ^user.id))
{:ok, {user, []}}
end
end)
end
end