Performance: - schedule_live: preload page once instead of get_schedule!/1 per row - alert_live + alerts: DB-side status filter; Repo.aggregate counts replace length/Enum.count over 500-row fetches on every event - dashboard_live: drop duplicate get_device_status_counts; cap active alerts to 20 + use count_active_alerts/1 - maintenance.active_windows_for_device: 3 round-trips collapsed into one query (per-site-id branch keeps Postgres parameter types unambiguous) - sites.build_site_tree: O(N^2) -> O(N) via group_by(parent_site_id) - accounts.sole_owner_organizations: single group_by + having instead of per-org Repo.aggregate loop - agents + agent_live (org + admin): count_assigned_devices_batch/1 + count_agent_polling_targets/1 (no preloads) - alert_digest_worker: list_alerts_by_ids/1 batches digest fetch - gaiia: distinct: true at DB; limit 50 on bidirectional ilike Indexes: - maintenance_windows(organization_id, starts_at, ends_at) WHERE suppress_alerts = true - alerts(check_id) WHERE resolved_at IS NULL Antipatterns: - agents.delete_agent_token: PubSub.broadcast moved outside Repo.transaction so a rollback no longer leaves subscribers acting on a non-existent deletion - integrations_controller.to_atom_keys: replaced String.to_existing_atom on user-controlled JSON keys with explicit allowlist - 9 Task.start callsites converted to Task.Supervisor.start_child(Towerops.TaskSupervisor, ...) so background DB writes survive shutdown (test-mode discovery shims left as-is for sandbox semantics)
132 lines
4.3 KiB
Elixir
132 lines
4.3 KiB
Elixir
defmodule ToweropsWeb.Api.V1.IntegrationsController do
|
|
@moduledoc "API controller for third-party integrations."
|
|
use ToweropsWeb, :controller
|
|
|
|
import ToweropsWeb.Api.ErrorHelpers, only: [translate_errors: 1]
|
|
|
|
alias Towerops.Integrations
|
|
|
|
def index(conn, _params) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
integrations =
|
|
organization_id
|
|
|> Integrations.list_integrations()
|
|
|> Enum.map(&format_integration/1)
|
|
|
|
json(conn, %{data: integrations})
|
|
end
|
|
|
|
def create(conn, %{"integration" => attrs}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case Integrations.create_integration(organization_id, to_atom_keys(attrs)) do
|
|
{:ok, integration} ->
|
|
conn |> put_status(:created) |> json(%{data: format_integration(integration)})
|
|
|
|
{:error, changeset} ->
|
|
conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
end
|
|
|
|
def create(conn, _params) do
|
|
conn |> put_status(:bad_request) |> json(%{error: "Missing 'integration' parameter"})
|
|
end
|
|
|
|
def show(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case Integrations.get_integration_by_id(id) do
|
|
{:ok, integration} when integration.organization_id == organization_id ->
|
|
json(conn, %{data: format_integration(integration)})
|
|
|
|
_ ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Integration not found"})
|
|
end
|
|
end
|
|
|
|
def update(conn, %{"id" => id, "integration" => attrs}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case Integrations.get_integration_by_id(id) do
|
|
{:ok, integration} when integration.organization_id == organization_id ->
|
|
case Integrations.update_integration(integration, to_atom_keys(attrs)) do
|
|
{:ok, updated} ->
|
|
json(conn, %{data: format_integration(updated)})
|
|
|
|
{:error, changeset} ->
|
|
conn |> put_status(:unprocessable_entity) |> json(%{errors: translate_errors(changeset)})
|
|
end
|
|
|
|
_ ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Integration not found"})
|
|
end
|
|
end
|
|
|
|
def update(conn, _params) do
|
|
conn |> put_status(:bad_request) |> json(%{error: "Missing 'integration' parameter"})
|
|
end
|
|
|
|
def delete(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case Integrations.get_integration_by_id(id) do
|
|
{:ok, integration} when integration.organization_id == organization_id ->
|
|
case Integrations.delete_integration(integration) do
|
|
{:ok, _} -> conn |> put_status(:no_content) |> send_resp(204, "")
|
|
{:error, _} -> conn |> put_status(:unprocessable_entity) |> json(%{error: "Could not delete integration"})
|
|
end
|
|
|
|
_ ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Integration not found"})
|
|
end
|
|
end
|
|
|
|
def test_connection(conn, %{"id" => id}) do
|
|
organization_id = conn.assigns.current_organization_id
|
|
|
|
case Integrations.get_integration_by_id(id) do
|
|
{:ok, integration} when integration.organization_id == organization_id ->
|
|
# Simple connectivity test — just verify the integration exists and is configured
|
|
json(conn, %{data: %{status: "ok", provider: integration.provider}})
|
|
|
|
_ ->
|
|
conn |> put_status(:not_found) |> json(%{error: "Integration not found"})
|
|
end
|
|
end
|
|
|
|
defp format_integration(integration) do
|
|
%{
|
|
id: integration.id,
|
|
provider: integration.provider,
|
|
enabled: integration.enabled,
|
|
sync_interval_minutes: integration.sync_interval_minutes,
|
|
last_synced_at: integration.last_synced_at,
|
|
last_sync_status: integration.last_sync_status,
|
|
last_sync_message: integration.last_sync_message,
|
|
inserted_at: integration.inserted_at,
|
|
updated_at: integration.updated_at
|
|
}
|
|
end
|
|
|
|
# Allowlist of integration attribute keys that can be set via the API.
|
|
# We avoid String.to_existing_atom on user input so an attacker cannot probe
|
|
# for atoms or trigger ArgumentError 500s. Unknown keys are silently dropped
|
|
# by the changeset's `cast/3` allowlist.
|
|
@allowed_keys ~w(
|
|
provider
|
|
enabled
|
|
config
|
|
api_key
|
|
api_url
|
|
sync_interval_minutes
|
|
)
|
|
|
|
defp to_atom_keys(map) when is_map(map) do
|
|
for {k, v} <- map, k in @allowed_keys, into: %{} do
|
|
{String.to_atom(k), v}
|
|
end
|
|
end
|
|
|
|
defp to_atom_keys(other), do: other
|
|
end
|