From 7a7b30f7bf65e1e9b43ffadf992bc27483951edb Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 21 Apr 2026 13:32:42 -0500 Subject: [PATCH] chore(specs): add @spec to public API in accounts/release/backtest --- lib/microwaveprop/accounts.ex | 32 +++++++++++++++++++++++++++++ lib/microwaveprop/accounts/scope.ex | 1 + lib/microwaveprop/accounts/user.ex | 7 +++++++ lib/microwaveprop/backtest.ex | 1 + lib/microwaveprop/release.ex | 9 ++++++++ lib/microwaveprop/repo_listener.ex | 1 + 6 files changed, 51 insertions(+) diff --git a/lib/microwaveprop/accounts.ex b/lib/microwaveprop/accounts.ex index 27bc106e..21c69e56 100644 --- a/lib/microwaveprop/accounts.ex +++ b/lib/microwaveprop/accounts.ex @@ -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 diff --git a/lib/microwaveprop/accounts/scope.ex b/lib/microwaveprop/accounts/scope.ex index c497273a..2903411a 100644 --- a/lib/microwaveprop/accounts/scope.ex +++ b/lib/microwaveprop/accounts/scope.ex @@ -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 diff --git a/lib/microwaveprop/accounts/user.ex b/lib/microwaveprop/accounts/user.ex index 6c68d3f1..bbe06ce7 100644 --- a/lib/microwaveprop/accounts/user.ex +++ b/lib/microwaveprop/accounts/user.ex @@ -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) diff --git a/lib/microwaveprop/backtest.ex b/lib/microwaveprop/backtest.ex index e46fbef0..38e72aa3 100644 --- a/lib/microwaveprop/backtest.ex +++ b/lib/microwaveprop/backtest.ex @@ -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 diff --git a/lib/microwaveprop/release.ex b/lib/microwaveprop/release.ex index 21a0a02e..ff4abf62 100644 --- a/lib/microwaveprop/release.ex +++ b/lib/microwaveprop/release.ex @@ -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})) diff --git a/lib/microwaveprop/repo_listener.ex b/lib/microwaveprop/repo_listener.ex index 5bcee981..6c5b050f 100644 --- a/lib/microwaveprop/repo_listener.ex +++ b/lib/microwaveprop/repo_listener.ex @@ -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