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 import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1] alias Towerops.Devices alias Towerops.Devices.Device alias Towerops.Sites.Site alias Towerops.Workers.DiscoveryWorker alias ToweropsWeb.Api.ParamFilter alias ToweropsWeb.ScopedResource @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. The device can be assigned to a site or directly to the organization. Request body: { "device": { "site_id": "uuid", # optional - if not provided, device is assigned directly to organization "organization_id": "uuid", # optional - defaults to authenticated organization if not provided "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" (can be "1", "2c", or "3") "snmp_community": "public", # optional, inherits from site/org if not set "snmp_port": 161, # optional, default 161 "device_role": "router", # optional, one of: server, switch, router, access_point, backhaul, other (default: "other") # SNMPv3 fields (only used when snmp_version is "3"): "snmpv3_security_level": "authPriv", # optional, one of: noAuthNoPriv, authNoPriv, authPriv "snmpv3_username": "snmpuser", # optional "snmpv3_auth_protocol": "SHA-256", # optional, one of: MD5, SHA, SHA-224, SHA-256, SHA-384, SHA-512 "snmpv3_auth_password": "authpass", # optional "snmpv3_priv_protocol": "AES", # optional, one of: DES, AES, AES-192, AES-256 "snmpv3_priv_password": "privpass" # optional } } Response: { "id": "uuid", "name": "Core Router", "ip_address": "192.168.1.1", "site_id": "uuid", "organization_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] device_params = device_params |> ParamFilter.strip_sensitive() |> default_organization_id(organization_id) case verify_site_access(device_params["site_id"], organization_id) do :ok -> create_and_discover_device(conn, device_params, current_user) {: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 case ScopedResource.fetch(Device, id, organization_id) do {:ok, device} -> json(conn, format_device_details(device)) {:error, :forbidden} -> conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) end 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 case ScopedResource.fetch(Device, id, organization_id) do {:ok, device} -> case Devices.update_device(device, ParamFilter.strip_sensitive(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 {:error, :forbidden} -> conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) end 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 case ScopedResource.fetch(Device, id, organization_id) do {:ok, device} -> 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 {:error, :forbidden} -> conn |> put_status(:forbidden) |> json(%{error: "Access denied to this device"}) {:error, :not_found} -> conn |> put_status(:not_found) |> json(%{error: "Device not found"}) end end defp default_organization_id(device_params, organization_id) do Map.put(device_params, "organization_id", organization_id) end defp create_and_discover_device(conn, device_params, current_user) do opts = if current_user && current_user.is_superuser, do: [bypass_limits: true], else: [] case Devices.create_device(device_params, opts) do {:ok, device} -> _ = maybe_enqueue_discovery(device) conn |> put_status(:created) |> json(format_device(device)) {:error, %Ecto.Changeset{} = changeset} -> conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)}) end end defp maybe_enqueue_discovery(device) do if device.snmp_enabled do DiscoveryWorker.enqueue(device.id) end end defp verify_site_access(nil, _organization_id), do: :ok defp verify_site_access(site_id, organization_id) do case ScopedResource.fetch(Site, site_id, organization_id) do {:ok, _site} -> :ok {:error, :forbidden} -> {:error, "Access denied to this site"} {:error, :not_found} -> {:error, "Site not found"} end end defp format_device(device) do %{ id: device.id, name: device.name, ip_address: device.ip_address, site_id: device.site_id, organization_id: device.organization_id, monitoring_enabled: device.monitoring_enabled, snmp_enabled: device.snmp_enabled, snmp_version: device.snmp_version, snmp_port: device.snmp_port, device_role: device.device_role, snmpv3_security_level: device.snmpv3_security_level, snmpv3_username: device.snmpv3_username, snmpv3_auth_protocol: device.snmpv3_auth_protocol, snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password), snmpv3_priv_protocol: device.snmpv3_priv_protocol, snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password), 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, organization_id: device.organization_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, device_role: device.device_role, snmpv3_security_level: device.snmpv3_security_level, snmpv3_username: device.snmpv3_username, snmpv3_auth_protocol: device.snmpv3_auth_protocol, snmpv3_auth_password_set: !is_nil(device.snmpv3_auth_password), snmpv3_priv_protocol: device.snmpv3_priv_protocol, snmpv3_priv_password_set: !is_nil(device.snmpv3_priv_password), inserted_at: device.inserted_at } end end