prop/lib/microwaveprop/beacon_monitors.ex
Graham McIntire ca842e3add
feat(admin): beacon monitor reassign, token regen, assigned_by display, user monitor management
Uncommitted work from previous session:
- Add list_users_select/0 helper for admin select dropdowns
- Add regenerate_token/1 context function
- Preload assigned_by association on monitor queries
- Add admin nav link to beacon monitors
- Fix JS event targets (remove redundant target: @myself)
- Add reassign user form to admin monitor show page
- Add regenerate token button to admin monitor show page
- Show assigned_by admin on monitor detail page
- Add beacon monitors section to user management edit page
- Add unassign monitor capability from user management
- Add full test coverage for all new functionality
- Fix pre-existing /account route warning (redirect → /users/settings)
2026-07-22 16:27:21 -05:00

222 lines
7.3 KiB
Elixir

defmodule Microwaveprop.BeaconMonitors do
@moduledoc """
The BeaconMonitors context: manages the physical SDR-based monitor
hardware assigned to users.
Each monitor has a unique random token the `propmonitor` client uses
to authenticate its measurement uploads.
"""
import Ecto.Query
alias Ecto.Query.CastError
alias Microwaveprop.Accounts.User
alias Microwaveprop.BeaconMonitors.BeaconMonitor
alias Microwaveprop.Repo
@token_bytes 32
# ── User-facing queries ──────────────────────────────────────────
@doc """
Returns all monitors assigned to the given user, newest first.
Preloads the beacon for display.
"""
@spec list_monitors_for_user(User.t()) :: [BeaconMonitor.t()]
def list_monitors_for_user(%User{id: user_id}) do
Repo.all(
from m in BeaconMonitor,
where: m.user_id == ^user_id,
order_by: [desc: m.inserted_at],
preload: [:beacon]
)
end
@doc """
Returns a single monitor. Preloads beacon and user relations.
"""
@spec get_monitor!(Ecto.UUID.t()) :: BeaconMonitor.t()
def get_monitor!(monitor_id) do
BeaconMonitor |> Repo.get!(monitor_id) |> Repo.preload([:beacon, :user, :assigned_by])
end
@doc """
Returns a single monitor if owned by the given user, nil otherwise.
"""
@spec get_monitor_for_user(Ecto.UUID.t(), User.t()) :: BeaconMonitor.t() | nil
def get_monitor_for_user(monitor_id, %User{id: user_id}) do
Repo.one(
from m in BeaconMonitor,
where: m.id == ^monitor_id and m.user_id == ^user_id,
preload: [:beacon]
)
end
# ── Admin queries ────────────────────────────────────────────────
@doc """
Returns all monitors, newest first, with user and beacon preloaded.
Accepts preload overrides via options.
"""
@spec list_all_monitors(keyword()) :: [BeaconMonitor.t()]
def list_all_monitors(opts \\ []) do
preloads = Keyword.get(opts, :preload, [:user, :beacon, :assigned_by])
from(m in BeaconMonitor, order_by: [desc: m.inserted_at])
|> Repo.all()
|> Repo.preload(preloads)
end
# ── Test / convenience helpers ───────────────────────────────────
@doc """
Creates a monitor and assigns it to the given user. The user acts as
both the assigned owner and the creating admin. Used by tests and
any legacy callers.
"""
@spec create_monitor(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_monitor(%User{} = user, attrs) when is_list(attrs) or is_map(attrs) do
attrs =
attrs
|> Map.new(fn {k, v} -> {to_string(k), v} end)
|> Map.put("user_id", user.id)
create_hardware(user, attrs)
end
# ── Admin provisioning ───────────────────────────────────────────
@doc """
Creates a new hardware monitor with the given attrs. Generates a
unique auth token. Expects `user_id` and `assigned_by_id` to be set
in attrs.
"""
@spec create_hardware(User.t(), map()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def create_hardware(%User{} = admin, attrs) do
attrs = Map.put(attrs, "assigned_by_id", admin.id)
%BeaconMonitor{token: generate_token()}
|> BeaconMonitor.provision_changeset(attrs)
|> Repo.insert()
end
@doc """
Updates the monitor's configuration (beacon, frequency, mode, etc).
Used by both admins and the assigned user.
"""
@spec update_config(BeaconMonitor.t(), map()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def update_config(%BeaconMonitor{} = monitor, attrs) do
monitor
|> BeaconMonitor.config_changeset(attrs)
|> Repo.update()
end
@doc """
Updates the monitor's hardware provisioning fields. Admin-only.
"""
@spec update_hardware(BeaconMonitor.t(), map()) ::
{:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def update_hardware(%BeaconMonitor{} = monitor, attrs) do
monitor
|> BeaconMonitor.provision_changeset(attrs)
|> Repo.update()
end
@doc """
Reassigns a monitor to a different user. Returns `{:error, :not_found}`
if the target user does not exist.
"""
@spec assign_to_user(BeaconMonitor.t(), User.t(), User.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found | Ecto.Changeset.t()}
def assign_to_user(%BeaconMonitor{} = monitor, %User{id: _} = admin, %User{id: new_user_id}) do
monitor
|> BeaconMonitor.provision_changeset(%{
user_id: new_user_id,
assigned_by_id: admin.id
})
|> Repo.update()
end
@doc """
Deletes a monitor. Works for both admin and the assigned user.
"""
@spec delete_monitor(User.t(), Ecto.UUID.t()) ::
{:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor(%User{id: user_id}, monitor_id) do
query =
from m in BeaconMonitor,
where: m.id == ^monitor_id and m.user_id == ^user_id
case Repo.one(query) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
rescue
CastError -> {:error, :not_found}
end
@doc """
Deletes a monitor by its ID without user scoping. Admin-only.
"""
@spec delete_monitor!(Ecto.UUID.t()) :: {:ok, BeaconMonitor.t()} | {:error, :not_found}
def delete_monitor!(monitor_id) do
case Repo.get(BeaconMonitor, monitor_id) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
rescue
CastError -> {:error, :not_found}
end
# ── Auth / heartbeat ─────────────────────────────────────────────
@doc """
Looks up a monitor by its token. Returns nil if not found.
"""
@spec get_monitor_by_token(String.t()) :: BeaconMonitor.t() | nil
def get_monitor_by_token(token) when is_binary(token) do
Repo.get_by(BeaconMonitor, token: token)
end
@doc """
Stamps `last_seen_at` to the given timestamp (defaults to "now") for
the supplied monitor. Used by the measurement ingest endpoint as
effectively a heartbeat — every accepted upload nudges the monitor's
last-seen marker forward.
"""
@spec touch_last_seen(BeaconMonitor.t(), DateTime.t() | nil) :: {non_neg_integer(), nil}
def touch_last_seen(%BeaconMonitor{id: id}, at \\ nil) do
at = DateTime.truncate(at || DateTime.utc_now(), :second)
Repo.update_all(
from(m in BeaconMonitor, where: m.id == ^id),
set: [last_seen_at: at]
)
end
@doc """
Returns a blank changeset for rendering a new-monitor form.
"""
@spec change_monitor(map()) :: Ecto.Changeset.t()
def change_monitor(attrs \\ %{}) do
BeaconMonitor.provision_changeset(%BeaconMonitor{}, attrs)
end
@doc """
Regenerates the monitor's auth token, returning the updated monitor
with its new token.
"""
@spec regenerate_token(BeaconMonitor.t()) :: {:ok, BeaconMonitor.t()} | {:error, Ecto.Changeset.t()}
def regenerate_token(%BeaconMonitor{} = monitor) do
monitor
|> Ecto.Changeset.change(%{token: generate_token()})
|> Repo.update()
end
defp generate_token do
@token_bytes
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
end
end