defmodule ToweropsWeb.Api.V1.DevicesController do @moduledoc """ API controller for managing devices. All endpoints require API token authentication and operations are scoped to the organization associated with the token. """ use ToweropsWeb, :controller alias Towerops.Devices alias Towerops.Workers.DiscoveryWorker @doc """ GET /api/v1/devices Lists all devices for the authenticated organization. Query params: - site_id: Filter by site (optional) Response: { "devices": [ { "id": "uuid", "name": "Core Router", "ip_address": "192.168.1.1", "site_id": "uuid", "monitoring_enabled": true, "snmp_enabled": true, "inserted_at": "2026-01-15T19:44:25Z" } ] } """ def index(conn, params) do organization_id = conn.assigns.current_organization_id devices = organization_id |> Devices.list_organization_devices(params) |> Enum.map(&format_device/1) json(conn, %{devices: devices}) end @doc """ POST /api/v1/devices Creates a new device. Request body: { "device": { "site_id": "uuid", "name": "Core Router", "ip_address": "192.168.1.1", "description": "Main router", # optional "monitoring_enabled": true, # optional, default true "snmp_enabled": true, # optional, default true "snmp_version": "2c", # optional, default "2c" "snmp_community": "public", # optional, inherits from site/org if not set "snmp_port": 161 # optional, default 161 } } Response: { "id": "uuid", "name": "Core Router", "ip_address": "192.168.1.1", "site_id": "uuid", "monitoring_enabled": true, "snmp_enabled": true, "inserted_at": "2026-01-15T19:44:25Z" } """ def create(conn, %{"device" => device_params}) do organization_id = conn.assigns.current_organization_id current_user = conn.assigns[:current_user] # Verify site belongs to organization if site_id is provided case verify_site_access(device_params["site_id"], organization_id) do :ok -> # Superusers bypass device quota limits opts = if current_user && current_user.is_superuser, do: [bypass_limits: true], else: [] case Devices.create_device(device_params, opts) do {:ok, device} -> # Trigger SNMP discovery if enabled _ = if device.snmp_enabled do DiscoveryWorker.enqueue(device.id) end conn |> put_status(:created) |> json(format_device(device)) {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end {:error, reason} -> conn |> put_status(:forbidden) |> json(%{error: reason}) end end def create(conn, _params) do conn |> put_status(:bad_request) |> json(%{error: "Missing 'device' parameter"}) end @doc """ GET /api/v1/devices/:id Gets a single device by ID. Response: { "id": "uuid", "name": "Core Router", "ip_address": "192.168.1.1", "site_id": "uuid", "description": "Main router", "monitoring_enabled": true, "check_interval_seconds": 300, "snmp_enabled": true, "snmp_version": "2c", "snmp_port": 161, "inserted_at": "2026-01-15T19:44:25Z" } """ def show(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id device = id |> Devices.get_device!() |> Towerops.Repo.preload(:site) if device.site.organization_id == organization_id do json(conn, format_device_details(device)) else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) end @doc """ PATCH /api/v1/devices/:id Updates a device. Request body: { "device": { "name": "Updated Name", "ip_address": "192.168.1.2" } } Response: { "id": "uuid", "name": "Updated Name", "ip_address": "192.168.1.2", ... } """ def update(conn, %{"id" => id, "device" => device_params}) do organization_id = conn.assigns.current_organization_id device = id |> Devices.get_device!() |> Towerops.Repo.preload(:site) if device.site.organization_id == organization_id do case Devices.update_device(device, device_params) do {:ok, updated_device} -> json(conn, format_device_details(updated_device)) {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) end def update(conn, _params) do conn |> put_status(:bad_request) |> json(%{error: "Missing 'device' parameter"}) end @doc """ DELETE /api/v1/devices/:id Deletes a device. Response: { "success": true } """ def delete(conn, %{"id" => id}) do organization_id = conn.assigns.current_organization_id device = id |> Devices.get_device!() |> Towerops.Repo.preload(:site) if device.site.organization_id == organization_id do case Devices.delete_device(device) do {:ok, _device} -> json(conn, %{success: true}) {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end else conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) end rescue Ecto.NoResultsError -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) end # Private helpers defp verify_site_access(nil, _organization_id), do: {:error, "site_id is required"} defp verify_site_access(site_id, organization_id) do site = Towerops.Sites.get_site!(site_id) if site.organization_id == organization_id do :ok else {:error, "Access denied to this site"} end rescue Ecto.NoResultsError -> {:error, "Site not found"} end defp format_device(device) do %{ id: device.id, name: device.name, ip_address: device.ip_address, site_id: device.site_id, monitoring_enabled: device.monitoring_enabled, snmp_enabled: device.snmp_enabled, inserted_at: device.inserted_at } end defp format_device_details(device) do %{ id: device.id, name: device.name, ip_address: device.ip_address, site_id: device.site_id, description: device.description, monitoring_enabled: device.monitoring_enabled, check_interval_seconds: device.check_interval_seconds, snmp_enabled: device.snmp_enabled, snmp_version: device.snmp_version, snmp_port: device.snmp_port, inserted_at: device.inserted_at } end defp translate_errors(changeset) do Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> Regex.replace(~r"%{(\w+)}", msg, fn _, key -> opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() end) end) end end