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