diff --git a/lib/towerops_web/controllers/graphql_docs_controller.ex b/lib/towerops_web/controllers/graphql_docs_controller.ex new file mode 100644 index 00000000..b5f18ca9 --- /dev/null +++ b/lib/towerops_web/controllers/graphql_docs_controller.ex @@ -0,0 +1,26 @@ +defmodule ToweropsWeb.GraphQLDocsController do + @moduledoc "Controller for GraphQL API documentation page." + use ToweropsWeb, :controller + + alias Towerops.ApiTokens + + def index(conn, _params) do + sample_token = + case conn.assigns[:current_scope] do + %{user: user, organization: org} when not is_nil(user) and not is_nil(org) -> + tokens = ApiTokens.list_organization_api_tokens(org.id) + + if Enum.empty?(tokens) do + "your-api-token-here (create one in your organization settings)" + else + token = List.first(tokens) + "your-api-token-here (use: #{token.name})" + end + + _ -> + "YOUR_API_TOKEN" + end + + render(conn, :index, sample_token: sample_token) + end +end diff --git a/lib/towerops_web/controllers/graphql_docs_html.ex b/lib/towerops_web/controllers/graphql_docs_html.ex new file mode 100644 index 00000000..53f3a3f3 --- /dev/null +++ b/lib/towerops_web/controllers/graphql_docs_html.ex @@ -0,0 +1,6 @@ +defmodule ToweropsWeb.GraphQLDocsHTML do + @moduledoc "GraphQL API documentation pages." + use ToweropsWeb, :html + + embed_templates "graphql_docs_html/*" +end diff --git a/lib/towerops_web/controllers/graphql_docs_html/index.html.heex b/lib/towerops_web/controllers/graphql_docs_html/index.html.heex new file mode 100644 index 00000000..7f191eac --- /dev/null +++ b/lib/towerops_web/controllers/graphql_docs_html/index.html.heex @@ -0,0 +1,737 @@ + + + + +
+
+ + +
+

GraphQL API

+

+ The TowerOps GraphQL API provides a flexible, strongly-typed interface for querying and managing your network infrastructure. + Request exactly the data you need in a single request — no over-fetching, no under-fetching. +

+

+ The GraphQL API uses the same authentication as the + <.link navigate={~p"/docs/api"} class="text-blue-600 hover:text-blue-500 dark:text-blue-400">REST API + and provides equivalent functionality with the added benefit of nested queries and precise field selection. +

+
+ + +
+

Authentication

+

+ Authenticate using a Bearer token in the Authorization header. + API tokens can be created in your organization settings. +

+
+
Authorization: Bearer <%= @sample_token %>
+
+
+ + +
+

Endpoint

+

+ All GraphQL requests are sent as POST to a single endpoint: +

+
+
POST https://app.towerops.net/api/graphql
+
+

+ Send a JSON body with a query field + and optional variables: +

+
+
curl -X POST https://app.towerops.net/api/graphql \
+  -H "Authorization: Bearer <%= @sample_token %>" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "{ devices { id name ipAddress status } }"}'
+
+
+ + +
+

Query: Devices

+ +

devices

+

List all devices. Optionally filter by site or status.

+
+ + + + + + + + + + + +
ArgumentTypeDescription
siteIdIDFilter by site
statusStringFilter by status (up, down, unknown)
limitIntMax results (default: 100)
+
+
+
{
+  devices(siteId: "uuid", status: "up") {
+    id
+    name
+    ipAddress
+    status
+    lastCheckedAt
+    monitoringEnabled
+    snmpEnabled
+    deviceRole
+    site {
+      id
+      name
+      location
+    }
+  }
+}
+
+ +

device

+

Get a single device by ID.

+
+
{
+  device(id: "device-uuid") {
+    id
+    name
+    ipAddress
+    status
+    description
+    snmpVersion
+    snmpPort
+    checkIntervalSeconds
+    site { id name }
+  }
+}
+
+
+ + +
+

Query: Sites

+ +

sites

+

List all sites for the organization.

+
+
{
+  sites {
+    id
+    name
+    description
+    location
+    snmpVersion
+    snmpCommunity
+    insertedAt
+  }
+}
+
+ +

site

+

Get a single site by ID.

+
+
{ site(id: "site-uuid") { id name location } }
+
+
+ + +
+

Query: Alerts

+ +

alerts

+

List alerts. Filter by status or device.

+
+ + + + + + + + + + + +
ArgumentTypeDescription
statusStringactive, acknowledged, or resolved
deviceIdIDFilter by device
limitIntMax results (default: 100)
+
+
+
{
+  alerts(status: "active", limit: 10) {
+    id
+    alertType
+    message
+    triggeredAt
+    acknowledgedAt
+    resolvedAt
+    deviceId
+    device {
+      id
+      name
+      ipAddress
+    }
+    acknowledgedByEmail
+  }
+}
+
+
+ + +
+

Query: Agents

+
+
{
+  agents {
+    id
+    name
+    enabled
+    lastSeenAt
+    lastIp
+    isCloudPoller
+    deviceCount
+    metadata
+  }
+}
+
+# Single agent
+{ agent(id: "agent-uuid") { id name enabled deviceCount } }
+
+
+ + +
+

Query: Organization

+
+
{
+  organization {
+    id
+    name
+    slug
+    subscriptionPlan
+    useSites
+    snmpVersion
+    snmpCommunity
+    snmpPort
+    snmpTransport
+    mikrotikEnabled
+    mikrotikUsername
+    mikrotikPort
+    mikrotikUseSsl
+    defaultAgentTokenId
+  }
+}
+
+
+ + +
+

Query: Members

+
+
{
+  members {
+    id
+    email
+    role
+    insertedAt
+  }
+}
+
+
+ + +
+

Query: Integrations

+
+
{
+  integrations {
+    id
+    provider
+    enabled
+    syncIntervalMinutes
+    lastSyncedAt
+    lastSyncStatus
+  }
+}
+
+
+ + +
+

Query: Activity

+
+
{
+  activity(limit: 20) {
+    summary
+    detail
+    timestamp
+    severity
+    deviceName
+    siteName
+    link
+    type
+  }
+}
+
+
+ + +
+

Query: Metrics & Interfaces

+ +

deviceMetrics

+

Get time-series metrics for a device.

+
+ + + + + + + + + + + +
ArgumentTypeDescription
deviceIdID!Required device ID
sensorTypeStringFilter by sensor type
timeRangeStringe.g. "24h", "72h" (default: "24h")
+
+
+
{
+  deviceMetrics(deviceId: "device-uuid", timeRange: "48h") {
+    timestamp
+    value
+    status
+    checkName
+    checkType
+  }
+}
+
+ +

deviceInterfaces

+

Get SNMP interfaces for a device.

+
+
{
+  deviceInterfaces(deviceId: "device-uuid") {
+    id
+    ifIndex
+    ifName
+    ifDescr
+    ifType
+    ifSpeed
+    ifAdminStatus
+    ifOperStatus
+    ifAlias
+  }
+}
+
+
+ + +
+

Mutation: Devices

+ +

createDevice

+
+
mutation {
+  createDevice(input: {
+    ipAddress: "192.168.1.1"
+    name: "Core Router"
+    siteId: "site-uuid"
+    snmpEnabled: true
+    snmpVersion: "2c"
+    snmpCommunity: "public"
+  }) {
+    id
+    name
+    ipAddress
+    status
+  }
+}
+
+ +

updateDevice

+
+
mutation {
+  updateDevice(id: "device-uuid", input: {
+    name: "Updated Router"
+    monitoringEnabled: false
+  }) {
+    id
+    name
+    monitoringEnabled
+  }
+}
+
+ +

deleteDevice

+
+
mutation {
+  deleteDevice(id: "device-uuid") {
+    success
+    message
+  }
+}
+
+
+ + +
+

Mutation: Sites

+ +

createSite

+
+
mutation {
+  createSite(input: {
+    name: "Main Office"
+    location: "New York, NY"
+    snmpCommunity: "public"
+  }) {
+    id
+    name
+    location
+  }
+}
+
+ +

updateSite / deleteSite

+

Same pattern as device mutations.

+
+ + +
+

Mutation: Alert Actions

+ +

acknowledgeAlert

+
+
mutation {
+  acknowledgeAlert(id: "alert-uuid") {
+    id
+    acknowledgedAt
+    acknowledgedByEmail
+  }
+}
+
+ +

resolveAlert

+
+
mutation {
+  resolveAlert(id: "alert-uuid") {
+    id
+    resolvedAt
+  }
+}
+
+
+ + +
+

Mutation: Agent Management

+ +

createAgent

+

Creates a new agent token. The raw token is only returned once.

+
+
mutation {
+  createAgent(name: "DC1 Agent") {
+    id
+    name
+    token
+  }
+}
+
+ +

deleteAgent

+
+
mutation { deleteAgent(id: "agent-uuid") { success message } }
+
+
+ + +
+

Mutation: Organization Settings

+
+
mutation {
+  updateOrganization(input: {
+    name: "My Network"
+    useSites: true
+    snmpVersion: "2c"
+    snmpCommunity: "public"
+    mikrotikEnabled: true
+    mikrotikUsername: "admin"
+    mikrotikPort: 8729
+    mikrotikUseSsl: true
+  }) {
+    id
+    name
+    useSites
+    snmpVersion
+    mikrotikEnabled
+  }
+}
+
+
+ + +
+

Mutation: Members

+ +

sendInvitation

+
+
mutation {
+  sendInvitation(email: "user@example.com", role: "admin") {
+    id
+    email
+    role
+    expiresAt
+  }
+}
+
+ +

updateMemberRole

+
+
mutation {
+  updateMemberRole(id: "user-uuid", role: "admin") {
+    id
+    email
+    role
+  }
+}
+
+ +

removeMember / cancelInvitation

+
+
mutation { removeMember(id: "user-uuid") { success message } }
+mutation { cancelInvitation(id: "invitation-uuid") { success message } }
+
+
+ + +
+

Mutation: Integrations

+ +

createIntegration

+
+
mutation {
+  createIntegration(input: {
+    provider: "preseem"
+    enabled: true
+    credentials: "{\"api_key\": \"xxx\"}"
+    syncIntervalMinutes: 10
+  }) {
+    id
+    provider
+    enabled
+  }
+}
+
+ +

testIntegration

+
+
mutation {
+  testIntegration(id: "integration-uuid") {
+    success
+    message
+  }
+}
+
+
+ + +
+

Types Reference

+

+ All field names use camelCase in GraphQL queries (Absinthe automatically converts from Elixir's snake_case). +

+ +

Device

+

+ id, name, ipAddress, description, displayOrder, status, lastCheckedAt, lastStatusChangeAt, + monitoringEnabled, checkIntervalSeconds, snmpEnabled, snmpVersion, snmpCommunity, snmpCommunitySource, + snmpPort, snmpTransport, snmpTransportSource, lastDiscoveryAt, lastSnmpPollAt, + snmpv3SecurityLevel, snmpv3Username, snmpv3AuthProtocol, snmpv3PrivProtocol, snmpv3CredentialSource, + mikrotikUsername, mikrotikPort, mikrotikSshPort, mikrotikUseSsl, mikrotikEnabled, mikrotikCredentialSource, + deviceRole, deviceRoleSource, siteId, organizationId, insertedAt, updatedAt, site +

+ +

Site

+

+ id, name, description, location, displayOrder, snmpVersion, snmpCommunity, snmpPort, snmpTransport, + snmpv3SecurityLevel, snmpv3Username, snmpv3AuthProtocol, snmpv3PrivProtocol, + mikrotikUsername, mikrotikPort, mikrotikSshPort, mikrotikUseSsl, mikrotikEnabled, + organizationId, agentTokenId, parentSiteId, insertedAt, updatedAt +

+ +

Alert

+

+ id, alertType, message, triggeredAt, acknowledgedAt, resolvedAt, emailSentAt, + deviceId, acknowledgedById, gaiiaImpact, insertedAt, device, acknowledgedByEmail +

+ +

Agent

+

+ id, name, enabled, isCloudPoller, allowRemoteDebug, lastSeenAt, lastIp, metadata, + organizationId, insertedAt, updatedAt, deviceCount +

+ +

Organization

+

+ id, name, slug, subscriptionPlan, useSites, snmpVersion, snmpCommunity, snmpPort, snmpTransport, + snmpv3SecurityLevel, snmpv3Username, snmpv3AuthProtocol, snmpv3PrivProtocol, + mikrotikEnabled, mikrotikUsername, mikrotikPort, mikrotikSshPort, mikrotikUseSsl, + defaultAgentTokenId, insertedAt, updatedAt +

+ +

Member

+

id, email, role, isDefault, userId, insertedAt

+ +

Integration

+

+ id, provider, enabled, syncIntervalMinutes, lastSyncedAt, lastSyncStatus, organizationId, insertedAt, updatedAt +

+ +

ActivityItem

+

summary, detail, timestamp, severity, icon, deviceName, siteName, link, type

+ +

Utility Types

+

+ DeleteResult: success (Boolean!), message (String)
+ TestResult: success (Boolean!), message (String)
+ MetricPoint: timestamp, value, status, checkName, checkType
+ Interface: id, ifIndex, ifName, ifDescr, ifType, ifSpeed, ifAdminStatus, ifOperStatus, ifAlias
+ Invitation: id, email, role, expiresAt, acceptedAt, insertedAt
+ AgentWithToken: id, name, enabled, token, insertedAt +

+
+ + +
+

curl Examples

+ +

List devices with site info

+
+
curl -X POST https://app.towerops.net/api/graphql \
+  -H "Authorization: Bearer <%= @sample_token %>" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "{ devices { id name ipAddress status site { id name location } } }"}'
+
+ +

Get active alerts

+
+
curl -X POST https://app.towerops.net/api/graphql \
+  -H "Authorization: Bearer <%= @sample_token %>" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "{ alerts(status: \"active\") { id alertType message triggeredAt device { name ipAddress } } }"}'
+
+ +

Create a device

+
+
curl -X POST https://app.towerops.net/api/graphql \
+  -H "Authorization: Bearer <%= @sample_token %>" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "mutation { createDevice(input: { ipAddress: \"192.168.1.1\", name: \"Core Router\", snmpEnabled: true }) { id name status } }"}'
+
+ +

Acknowledge an alert

+
+
curl -X POST https://app.towerops.net/api/graphql \
+  -H "Authorization: Bearer <%= @sample_token %>" \
+  -H "Content-Type: application/json" \
+  -d '{"query": "mutation { acknowledgeAlert(id: \"alert-uuid\") { id acknowledgedAt } }"}'
+
+ +

Using variables

+
+
curl -X POST https://app.towerops.net/api/graphql \
+  -H "Authorization: Bearer <%= @sample_token %>" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "query": "query GetDevice($id: ID!) { device(id: $id) { id name ipAddress status site { name } } }",
+    "variables": {"id": "device-uuid"}
+  }'
+
+
+ +
+
diff --git a/lib/towerops_web/graphql/context.ex b/lib/towerops_web/graphql/context.ex new file mode 100644 index 00000000..b5784938 --- /dev/null +++ b/lib/towerops_web/graphql/context.ex @@ -0,0 +1,31 @@ +defmodule ToweropsWeb.GraphQL.Context do + @moduledoc """ + Plug that builds the Absinthe context from the authenticated API connection. + + Reads the organization_id and user set by the ApiAuth plug and passes them + into the Absinthe context for use by resolvers. + """ + @behaviour Plug + + def init(opts), do: opts + + def call(conn, _opts) do + context = build_context(conn) + Absinthe.Plug.put_options(conn, context: context) + end + + defp build_context(conn) do + context = %{} + + context = + case conn.assigns[:current_organization_id] do + nil -> context + org_id -> Map.put(context, :organization_id, org_id) + end + + case conn.assigns[:current_user] do + nil -> context + user -> Map.put(context, :user, user) + end + end +end diff --git a/lib/towerops_web/graphql/resolvers/activity.ex b/lib/towerops_web/graphql/resolvers/activity.ex new file mode 100644 index 00000000..68ff28f8 --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/activity.ex @@ -0,0 +1,31 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.Activity do + @moduledoc "GraphQL resolvers for activity feed." + + alias Towerops.ActivityFeed + + def list(_parent, args, %{context: %{organization_id: org_id}}) do + opts = + [limit: Map.get(args, :limit, 50)] + |> maybe_add_type(args[:type]) + + items = ActivityFeed.list_org_activity(org_id, opts) + {:ok, items} + end + + def list(_parent, _args, _resolution), do: {:error, "Authentication required"} + + defp maybe_add_type(opts, nil), do: opts + + defp maybe_add_type(opts, type) do + case safe_to_atom(type) do + nil -> opts + atom -> Keyword.put(opts, :types, [atom]) + end + end + + defp safe_to_atom(str) do + String.to_existing_atom(str) + rescue + ArgumentError -> nil + end +end diff --git a/lib/towerops_web/graphql/resolvers/agent.ex b/lib/towerops_web/graphql/resolvers/agent.ex new file mode 100644 index 00000000..65f07c0b --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/agent.ex @@ -0,0 +1,71 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.Agent do + @moduledoc "GraphQL resolvers for agent queries and mutations." + + alias Towerops.Agents + + def list(_parent, _args, %{context: %{organization_id: org_id}}) do + agents = Agents.list_organization_agent_tokens(org_id) + {:ok, agents} + end + + def list(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def get(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + agent = Agents.get_agent_token!(id) + + if agent.organization_id == org_id do + {:ok, agent} + else + {:error, "Agent not found"} + end + rescue + Ecto.NoResultsError -> {:error, "Agent not found"} + end + + def get(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def create(_parent, %{name: name}, %{context: %{organization_id: org_id}}) do + case Agents.create_agent_token(org_id, name) do + {:ok, agent_token, raw_token} -> + {:ok, %{ + id: agent_token.id, + name: agent_token.name, + enabled: agent_token.enabled, + token: raw_token, + inserted_at: agent_token.inserted_at + }} + + {:error, changeset} -> + {:error, format_errors(changeset)} + end + end + + def create(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + agent = Agents.get_agent_token!(id) + + if agent.organization_id == org_id do + case Agents.delete_agent_token(id) do + {:ok, _} -> {:ok, %{success: true, message: "Agent deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete agent"}} + end + else + {:error, "Agent not found"} + end + rescue + Ecto.NoResultsError -> {:error, "Agent not found"} + end + + def delete(_parent, _args, _resolution), do: {:error, "Authentication required"} + + defp format_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) + |> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) + |> Enum.join("; ") + end +end diff --git a/lib/towerops_web/graphql/resolvers/integration.ex b/lib/towerops_web/graphql/resolvers/integration.ex new file mode 100644 index 00000000..61b5a7bc --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/integration.ex @@ -0,0 +1,77 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.Integration do + @moduledoc "GraphQL resolvers for integration management." + + alias Towerops.Integrations + + def list(_parent, _args, %{context: %{organization_id: org_id}}) do + integrations = Integrations.list_integrations(org_id) + {:ok, integrations} + end + + def list(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def create(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do + attrs = Map.new(input, fn {k, v} -> {k, v} end) + + case Integrations.create_integration(org_id, attrs) do + {:ok, integration} -> {:ok, integration} + {:error, changeset} -> {:error, format_errors(changeset)} + end + end + + def create(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def update(_parent, %{id: id, input: input}, %{context: %{organization_id: org_id}}) do + case Integrations.get_integration_by_id(id) do + {:ok, integration} when integration.organization_id == org_id -> + attrs = Map.new(input, fn {k, v} -> {k, v} end) + + case Integrations.update_integration(integration, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, format_errors(changeset)} + end + + _ -> + {:error, "Integration not found"} + end + end + + def update(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def delete(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + case Integrations.get_integration_by_id(id) do + {:ok, integration} when integration.organization_id == org_id -> + case Integrations.delete_integration(integration) do + {:ok, _} -> {:ok, %{success: true, message: "Integration deleted"}} + {:error, _} -> {:ok, %{success: false, message: "Could not delete integration"}} + end + + _ -> + {:error, "Integration not found"} + end + end + + def delete(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def test_connection(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + case Integrations.get_integration_by_id(id) do + {:ok, integration} when integration.organization_id == org_id -> + {:ok, %{success: true, message: "Connection OK (#{integration.provider})"}} + + _ -> + {:error, "Integration not found"} + end + end + + def test_connection(_parent, _args, _resolution), do: {:error, "Authentication required"} + + defp format_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) + |> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) + |> Enum.join("; ") + end +end diff --git a/lib/towerops_web/graphql/resolvers/member.ex b/lib/towerops_web/graphql/resolvers/member.ex new file mode 100644 index 00000000..c3614a6d --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/member.ex @@ -0,0 +1,88 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.Member do + @moduledoc "GraphQL resolvers for member and invitation management." + + alias Towerops.Organizations + alias Towerops.Organizations.Invitation + alias Towerops.Repo + + def list(_parent, _args, %{context: %{organization_id: org_id}}) do + members = Organizations.list_organization_members(org_id) + {:ok, members} + end + + def list(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def invite(_parent, %{email: email} = args, %{context: %{organization_id: org_id, user: user}}) do + role = Map.get(args, :role, "member") + + case Organizations.create_invitation(%{ + email: email, + role: role, + organization_id: org_id, + invited_by_id: user.id + }) do + {:ok, invitation} -> {:ok, invitation} + {:error, changeset} -> {:error, format_errors(changeset)} + end + end + + def invite(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def cancel_invitation(_parent, %{id: id}, %{context: %{organization_id: org_id}}) do + case Repo.get(Invitation, id) do + nil -> + {:error, "Invitation not found"} + + invitation -> + if invitation.organization_id == org_id do + case Repo.delete(invitation) do + {:ok, _} -> {:ok, %{success: true, message: "Invitation cancelled"}} + {:error, _} -> {:ok, %{success: false, message: "Could not cancel invitation"}} + end + else + {:error, "Invitation not found"} + end + end + end + + def cancel_invitation(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def remove(_parent, %{id: user_id}, %{context: %{organization_id: org_id}}) do + case Organizations.remove_member(org_id, user_id) do + {:ok, _} -> {:ok, %{success: true, message: "Member removed"}} + {:error, :cannot_remove_owner} -> {:error, "Cannot remove owner"} + {:error, :not_found} -> {:error, "Member not found"} + end + end + + def remove(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def update_role(_parent, %{id: user_id, role: role}, %{context: %{organization_id: org_id}}) do + case Organizations.update_member_role(org_id, user_id, role) do + {:ok, membership} -> + membership = Repo.preload(membership, :user) + {:ok, membership} + + {:error, :cannot_change_owner_role} -> + {:error, "Cannot change owner role"} + + {:error, :not_found} -> + {:error, "Member not found"} + + {:error, changeset} -> + {:error, format_errors(changeset)} + end + end + + def update_role(_parent, _args, _resolution), do: {:error, "Authentication required"} + + defp format_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) + |> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) + |> Enum.join("; ") + end +end diff --git a/lib/towerops_web/graphql/resolvers/organization.ex b/lib/towerops_web/graphql/resolvers/organization.ex new file mode 100644 index 00000000..372bed27 --- /dev/null +++ b/lib/towerops_web/graphql/resolvers/organization.ex @@ -0,0 +1,34 @@ +defmodule ToweropsWeb.GraphQL.Resolvers.Organization do + @moduledoc "GraphQL resolvers for organization queries and mutations." + + alias Towerops.Organizations + + def get(_parent, _args, %{context: %{organization_id: org_id}}) do + org = Organizations.get_organization!(org_id) + {:ok, org} + end + + def get(_parent, _args, _resolution), do: {:error, "Authentication required"} + + def update(_parent, %{input: input}, %{context: %{organization_id: org_id}}) do + org = Organizations.get_organization!(org_id) + attrs = Map.new(input, fn {k, v} -> {to_string(k), v} end) + + case Organizations.update_organization(org, attrs) do + {:ok, updated} -> {:ok, updated} + {:error, changeset} -> {:error, format_errors(changeset)} + end + end + + def update(_parent, _args, _resolution), do: {:error, "Authentication required"} + + defp format_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) + |> Enum.map(fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end) + |> Enum.join("; ") + end +end diff --git a/lib/towerops_web/router.ex b/lib/towerops_web/router.ex index 0aae05bb..1146a27d 100644 --- a/lib/towerops_web/router.ex +++ b/lib/towerops_web/router.ex @@ -63,6 +63,10 @@ defmodule ToweropsWeb.Router do plug RateLimit, type: :api end + pipeline :graphql_context do + plug ToweropsWeb.GraphQL.Context + end + # Health check endpoint for Kubernetes probes (no authentication required) scope "/", ToweropsWeb do get "/health", HealthController, :index @@ -76,6 +80,7 @@ defmodule ToweropsWeb.Router do get "/privacy", PageController, :privacy get "/terms", PageController, :terms get "/docs/api", ApiDocsController, :index + get "/docs/graphql", GraphQLDocsController, :index get "/invitations/:token", InvitationController, :show live "/help", HelpLive.Index, :index end @@ -116,6 +121,13 @@ defmodule ToweropsWeb.Router do get "/devices/:id", MobileController, :get_equipment end + # GraphQL API endpoint (requires API token authentication) + scope "/api/graphql" do + pipe_through [:api_v1, :rate_limit_api, :graphql_context] + + forward "/", Absinthe.Plug, schema: ToweropsWeb.GraphQL.Schema + end + # API v1 routes (requires API token authentication) # Rate limited to prevent API abuse scope "/api/v1", V1 do