- Add hardware/config fields migration to beacon_monitors table - Update BeaconMonitor schema with provision/config changesets - Add context functions: create_hardware, update_config, list_all_monitors - Remove user-facing monitor creation (browser POST + API POST) - Update settings page: show assigned monitors table with hardware info - Update profile page: show assigned monitors, remove register links - Fix all tests to match new API
211 lines
6.9 KiB
Elixir
211 lines
6.9 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])
|
|
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
|
|
|
|
defp generate_token do
|
|
@token_bytes
|
|
|> :crypto.strong_rand_bytes()
|
|
|> Base.url_encode64(padding: false)
|
|
end
|
|
end
|