chore(specs): add @spec to public API in accounts/release/backtest

This commit is contained in:
Graham McIntire 2026-04-21 13:32:42 -05:00
parent b7261e772c
commit 7a7b30f7bf
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
6 changed files with 51 additions and 0 deletions

View file

@ -24,6 +24,7 @@ defmodule Microwaveprop.Accounts do
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
@ -63,6 +64,7 @@ defmodule Microwaveprop.Accounts do
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
@ -82,16 +84,19 @@ defmodule Microwaveprop.Accounts do
** (Ecto.NoResultsError)
"""
@spec get_user!(Ecto.UUID.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
## Admin user management
@doc "Returns all users ordered by callsign."
@spec list_users() :: [User.t()]
def list_users do
Repo.all(from u in User, order_by: [asc: u.callsign])
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)
@ -99,11 +104,13 @@ defmodule Microwaveprop.Accounts do
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
@ -120,6 +127,7 @@ defmodule Microwaveprop.Accounts do
{:error, %Ecto.Changeset{}}
"""
@spec register_user(map()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()}
def register_user(attrs) do
%User{}
|> User.registration_changeset(attrs)
@ -129,6 +137,7 @@ defmodule Microwaveprop.Accounts do
@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
@ -141,6 +150,7 @@ defmodule Microwaveprop.Accounts do
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
@ -160,6 +170,7 @@ defmodule Microwaveprop.Accounts do
%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
@ -169,6 +180,7 @@ defmodule Microwaveprop.Accounts do
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}"
@ -196,6 +208,7 @@ defmodule Microwaveprop.Accounts do
%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
@ -214,6 +227,8 @@ defmodule Microwaveprop.Accounts do
{: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)
@ -225,6 +240,7 @@ defmodule Microwaveprop.Accounts do
@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)
@ -236,6 +252,7 @@ defmodule Microwaveprop.Accounts do
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)
@ -248,6 +265,8 @@ defmodule Microwaveprop.Accounts do
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
@ -265,6 +284,8 @@ defmodule Microwaveprop.Accounts do
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
@ -285,6 +306,11 @@ defmodule Microwaveprop.Accounts do
{: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}")
@ -304,6 +330,8 @@ defmodule Microwaveprop.Accounts do
{: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")
@ -314,6 +342,7 @@ defmodule Microwaveprop.Accounts do
@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
@ -328,6 +357,8 @@ defmodule Microwaveprop.Accounts do
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)
@ -337,6 +368,7 @@ defmodule Microwaveprop.Accounts do
@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

View file

@ -27,6 +27,7 @@ defmodule Microwaveprop.Accounts.Scope do
Returns nil if no user is given.
"""
@spec for_user(User.t() | nil) :: t() | nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end

View file

@ -27,6 +27,7 @@ defmodule Microwaveprop.Accounts.User do
Returns the email address that is automatically granted admin
privileges on registration.
"""
@spec admin_email() :: String.t()
def admin_email, do: @admin_email
@doc """
@ -43,6 +44,7 @@ defmodule Microwaveprop.Accounts.User do
* `:validate_unique` - Set to false to skip uniqueness validation.
Defaults to `true`.
"""
@spec registration_changeset(t() | Ecto.Changeset.t(), map(), keyword()) :: Ecto.Changeset.t()
def registration_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:callsign, :name, :email, :password])
@ -65,6 +67,7 @@ defmodule Microwaveprop.Accounts.User do
Admin-only changeset for editing another user's profile fields
and admin flag. Does not touch the password.
"""
@spec admin_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def admin_changeset(user, attrs) do
user
|> cast(attrs, [:callsign, :name, :email, :is_admin])
@ -113,6 +116,7 @@ defmodule Microwaveprop.Accounts.User do
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
@spec email_changeset(t() | Ecto.Changeset.t(), map(), keyword()) :: Ecto.Changeset.t()
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
@ -159,6 +163,7 @@ defmodule Microwaveprop.Accounts.User do
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
@spec password_changeset(t() | Ecto.Changeset.t(), map(), keyword()) :: Ecto.Changeset.t()
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
@ -197,6 +202,7 @@ defmodule Microwaveprop.Accounts.User do
@doc """
Confirms the account by setting `confirmed_at`.
"""
@spec confirm_changeset(t() | Ecto.Changeset.t()) :: Ecto.Changeset.t()
def confirm_changeset(user) do
now = DateTime.utc_now(:second)
change(user, confirmed_at: now)
@ -208,6 +214,7 @@ defmodule Microwaveprop.Accounts.User do
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
@spec valid_password?(t() | nil, String.t()) :: boolean()
def valid_password?(%Microwaveprop.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, hashed_password)

View file

@ -41,6 +41,7 @@ defmodule Microwaveprop.Backtest do
max: float | nil
}
@spec from_values([number()]) :: t()
def from_values([]), do: %__MODULE__{count: 0}
def from_values(values) when is_list(values) do

View file

@ -20,6 +20,7 @@ defmodule Microwaveprop.Release do
@app :microwaveprop
@spec migrate() :: [term()]
def migrate do
_ = load_app()
@ -28,29 +29,34 @@ defmodule Microwaveprop.Release do
end
end
@spec rollback(module(), integer()) :: {:ok, term(), term()}
def rollback(repo, version) do
_ = load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
@spec backtest(String.t()) :: :ok
def backtest(feature_name) when is_binary(feature_name) do
_ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "backtest", feature: feature_name}))
IO.puts("Enqueued backtest for #{feature_name} (job #{job.id})")
end
@spec backtest_all() :: :ok
def backtest_all do
_ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "backtest_all"}))
IO.puts("Enqueued consolidated backtest (job #{job.id})")
end
@spec climatology(pos_integer()) :: :ok
def climatology(min_samples \\ 3) do
_ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "climatology", min_samples: min_samples}))
IO.puts("Enqueued climatology build (job #{job.id})")
end
@spec native_backfill(pos_integer()) :: :ok
def native_backfill(limit \\ 500) do
_ = start_app()
@ -87,6 +93,7 @@ defmodule Microwaveprop.Release do
IO.puts("Done.")
end
@spec recalibrate() :: :ok
def recalibrate do
_ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "recalibrate"}))
@ -97,11 +104,13 @@ defmodule Microwaveprop.Release do
# propagation_scores, which are gone now that scores live as binary
# files on disk. Left as a stub that tells the operator what
# happened if they shell into a running release from muscle memory.
@spec scorer_diff(String.t()) :: :ok
def scorer_diff(_new_weights_json) do
_ = start_app()
IO.puts("scorer_diff is disabled: propagation_scores table + factors storage have been dropped.")
end
@spec native_derive(pos_integer()) :: :ok
def native_derive(limit \\ 10_000) do
_ = start_app()
{:ok, job} = Oban.insert(AdminTaskWorker.new(%{task: "native_derive", limit: limit}))

View file

@ -11,6 +11,7 @@ defmodule Microwaveprop.RepoListener do
@channels ["contact_status_changed", "oban_job_changed"]
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end