prop/lib/microwaveprop/beacon_monitors.ex
Graham McIntire b4b8d4ec47
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s
simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign
  normalization, lat/lon validation, grid/latlon derivation across 6 schemas
- Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue
  for Oban workers, UUID casting to replace CastError rescues
- Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions),
  subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2
- Extract Propagation.ScoreStore (528 lines): separate file I/O and cache
  management from scoring logic, 13 defdelegate passthroughs
- Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into
  3 function component modules (csv_upload, adif_upload, preview)
- Update 16 LiveViews to use shared helpers
2026-08-06 18:06:50 -05:00

228 lines
7.4 KiB
Elixir

defmodule Microwaveprop.BeaconMonitors do
@moduledoc """
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 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
case Ecto.UUID.cast(monitor_id) do
{:ok, uuid} ->
query =
from m in BeaconMonitor,
where: m.id == ^uuid and m.user_id == ^user_id
case Repo.one(query) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
:error ->
{:error, :not_found}
end
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 Ecto.UUID.cast(monitor_id) do
{:ok, uuid} ->
case Repo.get(BeaconMonitor, uuid) do
nil -> {:error, :not_found}
monitor -> Repo.delete(monitor)
end
:error ->
{:error, :not_found}
end
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