fix my-data page
This commit is contained in:
parent
57e4084a6e
commit
cd06f437bb
6 changed files with 354 additions and 4 deletions
208
CLAUDE.md
208
CLAUDE.md
|
|
@ -862,4 +862,210 @@ end)
|
|||
- Use `async: false` for tests with shared state (supervisor tests, integration tests)
|
||||
- never open the test coverage html files
|
||||
- remember when working in rust to always run cargo fmt before committing
|
||||
- never try to use npm to install js things, use esbuild built in to phoenix
|
||||
- never try to use npm to install js things, use esbuild built in to phoenix
|
||||
|
||||
## Dialyzer: Static Type Analysis
|
||||
|
||||
### Overview
|
||||
|
||||
Dialyzer is Elixir's static type analyzer that catches type errors, unreachable code, and contract violations. It uses "success typing" - only reporting errors it's certain about, avoiding false positives.
|
||||
|
||||
**Key Commands:**
|
||||
- `mix dialyzer` - Run Dialyzer analysis (builds PLT on first run)
|
||||
- `mix dialyzer --format dialyzer` - Show detailed error locations
|
||||
- `mix dialyzer --format ignore_file` - Generate `.dialyzer_ignore.exs` for gradual adoption
|
||||
- `mix dialyzer --quiet-with-result` - Cleaner output during analysis
|
||||
|
||||
**Configuration** (`mix.exs`):
|
||||
```elixir
|
||||
defp dialyzer do
|
||||
[
|
||||
plt_add_deps: :apps_direct, # Faster incremental analysis
|
||||
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
|
||||
flags: [:error_handling, :underspecs]
|
||||
]
|
||||
end
|
||||
```
|
||||
|
||||
### Type Specification Best Practices
|
||||
|
||||
#### 1. Opaque Types - Hide Implementation Details
|
||||
|
||||
Use `@opaque` to enforce data abstraction by hiding internal structure:
|
||||
|
||||
```elixir
|
||||
defmodule Token do
|
||||
@opaque t :: %__MODULE__{
|
||||
value: String.t(),
|
||||
expires_at: DateTime.t()
|
||||
}
|
||||
|
||||
defstruct [:value, :expires_at]
|
||||
|
||||
@spec new(String.t(), DateTime.t()) :: t()
|
||||
def new(value, expires_at), do: %__MODULE__{value: value, expires_at: expires_at}
|
||||
|
||||
@spec valid?(t()) :: boolean()
|
||||
def valid?(%__MODULE__{expires_at: exp}), do: DateTime.compare(DateTime.utc_now(), exp) == :lt
|
||||
end
|
||||
|
||||
# External modules can't access %Token{} internals directly
|
||||
# They must use Token.new/2 and Token.valid?/1
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Safe refactoring - internal changes don't break external code
|
||||
- Enforces API boundaries between modules
|
||||
- Prevents invalid data construction
|
||||
|
||||
#### 2. Recursive Types - Self-Referential Structures
|
||||
|
||||
Model tree-like or nested data with recursive types:
|
||||
|
||||
```elixir
|
||||
defmodule AST do
|
||||
@type expr ::
|
||||
{:const, integer()}
|
||||
| {:add, expr(), expr()}
|
||||
| {:multiply, expr(), expr()}
|
||||
|
||||
@spec evaluate(expr()) :: integer()
|
||||
def evaluate({:const, n}), do: n
|
||||
def evaluate({:add, left, right}), do: evaluate(left) + evaluate(right)
|
||||
def evaluate({:multiply, left, right}), do: evaluate(left) * evaluate(right)
|
||||
end
|
||||
```
|
||||
|
||||
#### 3. Generic Types - Parameterized Patterns
|
||||
|
||||
Create reusable, type-safe wrappers:
|
||||
|
||||
```elixir
|
||||
defmodule Result do
|
||||
@type t(ok, error) :: {:ok, ok} | {:error, error}
|
||||
|
||||
@spec map(t(a, e), (a -> b)) :: t(b, e) when a: var, b: var, e: var
|
||||
def map({:ok, value}, fun), do: {:ok, fun.(value)}
|
||||
def map({:error, _} = err, _fun), do: err
|
||||
|
||||
@spec map_error(t(o, a), (a -> b)) :: t(o, b) when o: var, a: var, b: var
|
||||
def map_error({:ok, _} = ok, _fun), do: ok
|
||||
def map_error({:error, err}, fun), do: {:error, fun.(err)}
|
||||
end
|
||||
|
||||
# Usage with specific types
|
||||
@spec fetch_user(id :: String.t()) :: Result.t(User.t(), :not_found | :timeout)
|
||||
```
|
||||
|
||||
### Common Warning Patterns
|
||||
|
||||
#### 1. Invalid Contracts - Return Type Mismatch
|
||||
|
||||
```elixir
|
||||
# ❌ Wrong - spec doesn't match all return paths
|
||||
@spec process(String.t()) :: {:ok, String.t()}
|
||||
def process(""), do: {:error, :empty} # Dialyzer warning!
|
||||
def process(str), do: {:ok, String.upcase(str)}
|
||||
|
||||
# ✅ Correct - spec matches all possible returns
|
||||
@spec process(String.t()) :: {:ok, String.t()} | {:error, :empty}
|
||||
def process(""), do: {:error, :empty}
|
||||
def process(str), do: {:ok, String.upcase(str)}
|
||||
```
|
||||
|
||||
#### 2. Function Application Arguments - Type Alignment
|
||||
|
||||
```elixir
|
||||
# ❌ Wrong - passing nil to String.upcase/1 which expects String.t()
|
||||
@spec format(String.t() | nil) :: String.t()
|
||||
def format(nil), do: "N/A"
|
||||
def format(name), do: String.upcase(name) # Dialyzer warning if name could be nil
|
||||
|
||||
# ✅ Correct - guard ensures String.upcase/1 only receives String.t()
|
||||
@spec format(String.t() | nil) :: String.t()
|
||||
def format(nil), do: "N/A"
|
||||
def format(name) when is_binary(name), do: String.upcase(name)
|
||||
```
|
||||
|
||||
#### 3. Opaque Type Mismatches - External Manipulation
|
||||
|
||||
```elixir
|
||||
# ❌ Wrong - external module accessing opaque type internals
|
||||
defmodule TokenConsumer do
|
||||
def check_expiry(%Token{expires_at: exp}), do: exp # Dialyzer warning!
|
||||
end
|
||||
|
||||
# ✅ Correct - use public API
|
||||
defmodule TokenConsumer do
|
||||
def check_expiry(token), do: Token.valid?(token)
|
||||
end
|
||||
```
|
||||
|
||||
#### 4. Range Errors - Incomplete Specs
|
||||
|
||||
```elixir
|
||||
# ❌ Wrong - spec doesn't account for empty list
|
||||
@spec sum(list(integer())) :: integer()
|
||||
def sum([]), do: nil # Dialyzer warning: returns nil, not integer
|
||||
def sum(list), do: Enum.sum(list)
|
||||
|
||||
# ✅ Correct - spec includes nil case or use default
|
||||
@spec sum(list(integer())) :: integer() | nil
|
||||
def sum([]), do: nil
|
||||
def sum(list), do: Enum.sum(list)
|
||||
|
||||
# ✅ Better - eliminate nil by using default
|
||||
@spec sum(list(integer())) :: integer()
|
||||
def sum([]), do: 0
|
||||
def sum(list), do: Enum.sum(list)
|
||||
```
|
||||
|
||||
### Practical Workflow
|
||||
|
||||
#### Start Broad, Narrow Gradually
|
||||
|
||||
Don't over-specify types prematurely. Start with broader types and refine as needed:
|
||||
|
||||
```elixir
|
||||
# Start broad
|
||||
@spec parse(String.t()) :: map()
|
||||
|
||||
# Narrow as structure becomes clear
|
||||
@spec parse(String.t()) :: %{name: String.t(), age: integer()}
|
||||
|
||||
# Finalize with custom type for reusability
|
||||
@type user_params :: %{name: String.t(), age: integer()}
|
||||
@spec parse(String.t()) :: user_params()
|
||||
```
|
||||
|
||||
#### Use Dialyzer for Gradual Adoption
|
||||
|
||||
When adding Dialyzer to legacy codebases:
|
||||
|
||||
1. Generate ignore file: `mix dialyzer --format ignore_file`
|
||||
2. Commit `.dialyzer_ignore.exs` to baseline
|
||||
3. Fix warnings incrementally, removing from ignore file
|
||||
4. Eventually delete ignore file when all issues resolved
|
||||
|
||||
#### Combine with Runtime Type Checking
|
||||
|
||||
Dialyzer is compile-time only. For runtime safety, consider TypeCheck or contracts:
|
||||
|
||||
```elixir
|
||||
# Dialyzer catches compile-time type errors
|
||||
@spec divide(integer(), integer()) :: float()
|
||||
def divide(a, b), do: a / b
|
||||
|
||||
# Runtime check prevents division by zero
|
||||
@spec divide_safe(integer(), integer()) :: {:ok, float()} | {:error, :division_by_zero}
|
||||
def divide_safe(_a, 0), do: {:error, :division_by_zero}
|
||||
def divide_safe(a, b), do: {:ok, a / b}
|
||||
```
|
||||
|
||||
### Important Reminders
|
||||
|
||||
- **Success Typing**: Dialyzer only reports errors it's certain about - comprehensive testing remains essential
|
||||
- **Not a Proof System**: Dialyzer won't catch all bugs, just type-related ones
|
||||
- **PLT Files**: Persist type lookup tables (`priv/plts/`) in gitignore - don't commit
|
||||
- **Incremental Analysis**: Use `plt_add_deps: :apps_direct` for faster rebuilds
|
||||
- **Never Suppress Valid Warnings**: Fix the root cause, don't silence Dialyzer with wrong specs
|
||||
|
|
@ -323,7 +323,7 @@ defmodule Mix.Tasks.Geoip.Import do
|
|||
entries =
|
||||
Enum.map(batch, fn block ->
|
||||
block
|
||||
|> Map.put(:id, Ecto.UUID.generate())
|
||||
|> Map.put(:id, Ecto.UUID.bingenerate())
|
||||
|> Map.put(:inserted_at, now)
|
||||
|> Map.put(:updated_at, now)
|
||||
end)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ defmodule Towerops.ApiTokens do
|
|||
iex> create_api_token(%{name: ""})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
@spec create_api_token(map()) :: {:ok, {ApiToken.t(), String.t()}} | {:error, Ecto.Changeset.t()}
|
||||
def create_api_token(attrs \\ %{}) do
|
||||
# Generate a random token
|
||||
raw_token = generate_token()
|
||||
|
|
@ -52,6 +53,7 @@ defmodule Towerops.ApiTokens do
|
|||
iex> list_organization_api_tokens(org_id)
|
||||
[%ApiToken{}, ...]
|
||||
"""
|
||||
@spec list_organization_api_tokens(binary()) :: [ApiToken.t()]
|
||||
def list_organization_api_tokens(organization_id) do
|
||||
ApiToken
|
||||
|> where([t], t.organization_id == ^organization_id)
|
||||
|
|
@ -67,6 +69,7 @@ defmodule Towerops.ApiTokens do
|
|||
iex> list_user_api_tokens(user_id)
|
||||
[%ApiToken{}, ...]
|
||||
"""
|
||||
@spec list_user_api_tokens(binary()) :: [ApiToken.t()]
|
||||
def list_user_api_tokens(user_id) do
|
||||
ApiToken
|
||||
|> where([t], t.user_id == ^user_id)
|
||||
|
|
@ -86,6 +89,7 @@ defmodule Towerops.ApiTokens do
|
|||
iex> get_api_token!("nonexistent")
|
||||
** (Ecto.NoResultsError)
|
||||
"""
|
||||
@spec get_api_token!(binary()) :: ApiToken.t()
|
||||
def get_api_token!(id), do: Repo.get!(ApiToken, id)
|
||||
|
||||
@doc """
|
||||
|
|
@ -103,6 +107,8 @@ defmodule Towerops.ApiTokens do
|
|||
iex> verify_token("invalid")
|
||||
{:error, :invalid_token}
|
||||
"""
|
||||
@spec verify_token(String.t()) ::
|
||||
{:ok, binary(), Towerops.Accounts.User.t() | nil} | {:error, :invalid_token}
|
||||
def verify_token(raw_token) do
|
||||
token_hash = hash_token(raw_token)
|
||||
|
||||
|
|
@ -136,6 +142,7 @@ defmodule Towerops.ApiTokens do
|
|||
iex> delete_api_token(api_token)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
@spec delete_api_token(ApiToken.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
||||
def delete_api_token(%ApiToken{} = api_token) do
|
||||
Repo.delete(api_token)
|
||||
end
|
||||
|
|
@ -151,6 +158,7 @@ defmodule Towerops.ApiTokens do
|
|||
iex> update_api_token(api_token, %{name: nil})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
"""
|
||||
@spec update_api_token(ApiToken.t(), map()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
||||
def update_api_token(%ApiToken{} = api_token, attrs) do
|
||||
api_token
|
||||
|> ApiToken.changeset(attrs)
|
||||
|
|
@ -159,6 +167,7 @@ defmodule Towerops.ApiTokens do
|
|||
|
||||
# Private functions
|
||||
|
||||
@spec generate_token() :: String.t()
|
||||
defp generate_token do
|
||||
# Generate a 32-byte random token and encode it as base64
|
||||
# Prefix with "towerops_" for easy identification
|
||||
|
|
@ -166,12 +175,14 @@ defmodule Towerops.ApiTokens do
|
|||
"towerops_" <> Base.url_encode64(random_bytes, padding: false)
|
||||
end
|
||||
|
||||
@spec hash_token(String.t()) :: String.t()
|
||||
defp hash_token(token) do
|
||||
:sha256
|
||||
|> :crypto.hash(token)
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
@spec update_last_used(ApiToken.t()) :: :ok
|
||||
defp update_last_used(token) do
|
||||
timestamp = DateTime.truncate(DateTime.utc_now(), :second)
|
||||
|
||||
|
|
@ -186,12 +197,14 @@ defmodule Towerops.ApiTokens do
|
|||
end
|
||||
end
|
||||
|
||||
@spec update_token_timestamp(ApiToken.t(), DateTime.t()) :: {:ok, ApiToken.t()} | {:error, Ecto.Changeset.t()}
|
||||
defp update_token_timestamp(token, timestamp) do
|
||||
token
|
||||
|> Ecto.Changeset.change(last_used_at: timestamp)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@spec spawn_async_update(ApiToken.t(), DateTime.t()) :: :ok
|
||||
defp spawn_async_update(token, timestamp) do
|
||||
parent = self()
|
||||
|
||||
|
|
|
|||
129
lib/towerops/result.ex
Normal file
129
lib/towerops/result.ex
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
defmodule Towerops.Result do
|
||||
@moduledoc """
|
||||
Generic result type for representing success or failure outcomes.
|
||||
|
||||
This module provides a type-safe way to handle operations that can succeed or fail,
|
||||
using Elixir's standard {:ok, value} | {:error, reason} pattern with enhanced type safety.
|
||||
|
||||
## Type Parameters
|
||||
|
||||
- `ok_value`: The type of value returned on success
|
||||
- `error_value`: The type of error returned on failure
|
||||
|
||||
## Examples
|
||||
|
||||
# Basic usage with string success and atom error
|
||||
@spec fetch_user(id :: String.t()) :: Result.t(User.t(), :not_found | :timeout)
|
||||
def fetch_user(id) do
|
||||
case Repo.get(User, id) do
|
||||
nil -> {:error, :not_found}
|
||||
user -> {:ok, user}
|
||||
end
|
||||
end
|
||||
|
||||
# Using helper functions
|
||||
Result.map({:ok, user}, &User.display_name/1)
|
||||
# => {:ok, "John Doe"}
|
||||
|
||||
Result.map_error({:error, :db_error}, fn _ -> :service_unavailable end)
|
||||
# => {:error, :service_unavailable}
|
||||
"""
|
||||
|
||||
@type t(ok_value, error_value) :: {:ok, ok_value} | {:error, error_value}
|
||||
|
||||
@type t(ok_value) :: t(ok_value, term())
|
||||
|
||||
@doc """
|
||||
Maps the success value using the given function.
|
||||
Error values are passed through unchanged.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Result.map({:ok, 5}, fn x -> x * 2 end)
|
||||
{:ok, 10}
|
||||
|
||||
iex> Result.map({:error, :not_found}, fn x -> x * 2 end)
|
||||
{:error, :not_found}
|
||||
"""
|
||||
@spec map(t(a, e), (a -> b)) :: t(b, e) when a: var, b: var, e: var
|
||||
def map({:ok, value}, fun) when is_function(fun, 1), do: {:ok, fun.(value)}
|
||||
def map({:error, _} = err, fun) when is_function(fun, 1), do: err
|
||||
|
||||
@doc """
|
||||
Maps the error value using the given function.
|
||||
Success values are passed through unchanged.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Result.map_error({:error, :timeout}, fn _ -> :network_error end)
|
||||
{:error, :network_error}
|
||||
|
||||
iex> Result.map_error({:ok, 42}, fn _ -> :network_error end)
|
||||
{:ok, 42}
|
||||
"""
|
||||
@spec map_error(t(o, a), (a -> b)) :: t(o, b) when o: var, a: var, b: var
|
||||
def map_error({:ok, _} = ok, fun) when is_function(fun, 1), do: ok
|
||||
def map_error({:error, err}, fun) when is_function(fun, 1), do: {:error, fun.(err)}
|
||||
|
||||
@doc """
|
||||
Chains together operations that return results.
|
||||
If the first result is an error, returns it immediately.
|
||||
Otherwise, applies the function to the success value.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Result.and_then({:ok, user}, fn u -> {:ok, u.email} end)
|
||||
{:ok, "user@example.com"}
|
||||
|
||||
iex> Result.and_then({:error, :not_found}, fn u -> {:ok, u.email} end)
|
||||
{:error, :not_found}
|
||||
"""
|
||||
@spec and_then(t(a, e), (a -> t(b, e))) :: t(b, e) when a: var, b: var, e: var
|
||||
def and_then({:ok, value}, fun) when is_function(fun, 1), do: fun.(value)
|
||||
def and_then({:error, _} = err, fun) when is_function(fun, 1), do: err
|
||||
|
||||
@doc """
|
||||
Unwraps a result, returning the success value or a default on error.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Result.unwrap_or({:ok, 42}, 0)
|
||||
42
|
||||
|
||||
iex> Result.unwrap_or({:error, :not_found}, 0)
|
||||
0
|
||||
"""
|
||||
@spec unwrap_or(t(a, term()), a) :: a when a: var
|
||||
def unwrap_or({:ok, value}, _default), do: value
|
||||
def unwrap_or({:error, _}, default), do: default
|
||||
|
||||
@doc """
|
||||
Checks if the result is a success.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Result.ok?({:ok, 42})
|
||||
true
|
||||
|
||||
iex> Result.ok?({:error, :not_found})
|
||||
false
|
||||
"""
|
||||
@spec ok?(t(any(), any())) :: boolean()
|
||||
def ok?({:ok, _}), do: true
|
||||
def ok?({:error, _}), do: false
|
||||
|
||||
@doc """
|
||||
Checks if the result is an error.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Result.error?({:error, :not_found})
|
||||
true
|
||||
|
||||
iex> Result.error?({:ok, 42})
|
||||
false
|
||||
"""
|
||||
@spec error?(t(any(), any())) :: boolean()
|
||||
def error?({:error, _}), do: true
|
||||
def error?({:ok, _}), do: false
|
||||
end
|
||||
|
|
@ -526,7 +526,7 @@ defmodule ToweropsWeb.AccountLive.MyData do
|
|||
{log.action}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
By: {log.actor_email || "System"}
|
||||
By: {if log.superuser, do: log.superuser.email, else: "System"}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-500">
|
||||
{Calendar.strftime(log.inserted_at, "%B %d, %Y at %I:%M %p")}
|
||||
|
|
|
|||
4
mix.exs
4
mix.exs
|
|
@ -91,8 +91,10 @@ defmodule Towerops.MixProject do
|
|||
defp dialyzer do
|
||||
[
|
||||
plt_file: {:no_warn, "priv/plts/dialyzer.plt"},
|
||||
# Faster incremental analysis
|
||||
plt_add_deps: :apps_direct,
|
||||
plt_add_apps: [:mix, :ex_unit],
|
||||
flags: [:unmatched_returns, :error_handling, :unknown],
|
||||
flags: [:unmatched_returns, :error_handling, :underspecs, :unknown],
|
||||
ignore_warnings: ".dialyzer_ignore.exs"
|
||||
]
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue