Files
wanderer/lib/wanderer_app_web/controllers/map_system_api_controller.ex
2025-06-13 21:14:07 -04:00

540 lines
18 KiB
Elixir

# lib/wanderer_app_web/controllers/map_system_api_controller.ex
defmodule WandererAppWeb.MapSystemAPIController do
@moduledoc """
API controller for managing map systems and their associated connections.
Provides CRUD operations and batch upsert for systems and connections.
"""
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias OpenApiSpex.Schema
alias WandererApp.Map.Operations
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas}
action_fallback WandererAppWeb.FallbackController
# -- JSON Schemas --
@map_system_schema %Schema{
type: :object,
properties: %{
id: %Schema{type: :string, description: "Map system UUID"},
map_id: %Schema{type: :string, description: "Map UUID"},
solar_system_id: %Schema{type: :integer, description: "EVE solar system ID"},
solar_system_name: %Schema{type: :string, description: "EVE solar system name"},
region_name: %Schema{type: :string, description: "EVE region name"},
custom_name: %Schema{type: :string, nullable: true, description: "Custom name for the system"},
position_x: %Schema{type: :integer, description: "X coordinate"},
position_y: %Schema{type: :integer, description: "Y coordinate"},
status: %Schema{
type: :integer,
description: "System status (0: unknown, 1: friendly, 2: warning, 3: targetPrimary, 4: targetSecondary, 5: dangerousPrimary, 6: dangerousSecondary, 7: lookingFor, 8: home)"
},
visible: %Schema{type: :boolean, description: "Visibility flag"},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag"},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :string, description: "Comma-separated list of labels"}
},
required: ~w(id map_id solar_system_id)a
}
@system_request_schema %Schema{
type: :object,
properties: %{
solar_system_id: %Schema{type: :integer, description: "EVE solar system ID"},
solar_system_name: %Schema{type: :string, description: "EVE solar system name"},
position_x: %Schema{type: :integer, description: "X coordinate"},
position_y: %Schema{type: :integer, description: "Y coordinate"},
status: %Schema{
type: :integer,
description: "System status (0: unknown, 1: friendly, 2: warning, 3: targetPrimary, 4: targetSecondary, 5: dangerousPrimary, 6: dangerousSecondary, 7: lookingFor, 8: home)"
},
visible: %Schema{type: :boolean, description: "Visibility flag"},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag"},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :string, description: "Comma-separated list of labels"}
},
required: ~w(solar_system_id)a,
example: %{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
position_x: 100,
position_y: 200,
visible: true,
labels: "market,hub"
}
}
@system_update_schema %Schema{
type: :object,
properties: %{
solar_system_name: %Schema{type: :string, description: "EVE solar system name", nullable: true},
position_x: %Schema{type: :integer, description: "X coordinate", nullable: true},
position_y: %Schema{type: :integer, description: "Y coordinate", nullable: true},
status: %Schema{
type: :integer,
description: "System status (0: unknown, 1: friendly, 2: warning, 3: targetPrimary, 4: targetSecondary, 5: dangerousPrimary, 6: dangerousSecondary, 7: lookingFor, 8: home)",
nullable: true
},
visible: %Schema{type: :boolean, description: "Visibility flag", nullable: true},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag", nullable: true},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :string, description: "Comma-separated list of labels"}
},
example: %{
solar_system_name: "Jita",
position_x: 101,
position_y: 202,
visible: false,
status: 0,
tag: "HQ",
locked: true,
labels: "market,hub"
}
}
@map_connection_schema %Schema{
type: :object,
properties: %{
id: %Schema{type: :string, description: "Connection UUID"},
map_id: %Schema{type: :string, description: "Map UUID"},
solar_system_source: %Schema{type: :integer},
solar_system_target: %Schema{type: :integer},
type: %Schema{type: :integer},
mass_status: %Schema{type: :integer, nullable: true},
time_status: %Schema{type: :integer, nullable: true},
ship_size_type: %Schema{type: :integer, nullable: true},
locked: %Schema{type: :boolean},
custom_info: %Schema{type: :string, nullable: true},
wormhole_type: %Schema{type: :string, nullable: true}
},
required: ~w(id map_id solar_system_source solar_system_target)a
}
@list_response_schema %Schema{
type: :object,
properties: %{
data: %Schema{
type: :object,
properties: %{
systems: %Schema{type: :array, items: @map_system_schema},
connections: %Schema{type: :array, items: @map_connection_schema}
}
}
},
example: %{
data: %{
systems: [
%{
id: "sys-uuid-1",
map_id: "map-uuid-1",
solar_system_id: 30_000_142,
solar_system_name: "Jita",
region_name: "The Forge",
custom_name: "Trade Hub Central",
position_x: 100.5,
position_y: 200.3,
status: "active",
visible: true,
description: "Trade hub",
tag: "HQ",
locked: false,
temporary_name: nil,
labels: ["market", "hub"]
}
],
connections: [
%{
id: "conn-uuid-1",
map_id: "map-uuid-1",
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
]
}
}
}
@detail_response_schema %Schema{
type: :object,
properties: %{
data: @map_system_schema
},
example: %{
data: %{
id: "sys-uuid-1",
map_id: "map-uuid-1",
solar_system_id: 30_000_142,
solar_system_name: "Jita",
region_name: "The Forge",
custom_name: "Trade Hub Central",
position_x: 100.5,
position_y: 200.3,
status: "active",
visible: true,
description: "Trade hub",
tag: "HQ",
locked: false,
temporary_name: nil,
labels: ["market", "hub"]
}
}
}
@delete_response_schema %Schema{
type: :object,
properties: %{deleted: %Schema{type: :boolean, description: "Deleted flag"}},
required: ["deleted"],
example: %{deleted: true}
}
@batch_response_schema %Schema{
type: :object,
properties: %{
data: %Schema{
type: :object,
properties: %{
systems: %Schema{
type: :object,
properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}},
required: ~w(created updated)a
},
connections: %Schema{
type: :object,
properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}, deleted: %Schema{type: :integer}},
required: ~w(created updated deleted)a
}
},
required: ~w(systems connections)a
}
},
example: %{
data: %{
systems: %{created: 2, updated: 1},
connections: %{created: 1, updated: 0, deleted: 1}
}
}
}
@batch_delete_schema %Schema{
type: :object,
properties: %{
system_ids: %Schema{
type: :array,
items: %Schema{type: :integer},
description: "IDs to delete"
},
connection_ids: %Schema{
type: :array,
items: %Schema{type: :string},
description: "Connection UUIDs to delete",
nullable: true
}
},
required: ["system_ids"],
example: %{
system_ids: [30_000_142, 30_000_143],
connection_ids: ["conn-uuid-1", "conn-uuid-2"]
}
}
@batch_delete_response_schema %Schema{
type: :object,
properties: %{deleted_count: %Schema{type: :integer, description: "Deleted count"}},
required: ["deleted_count"],
example: %{deleted_count: 2}
}
@batch_request_schema ApiSchemas.data_wrapper(%Schema{
type: :object,
properties: %{
systems: %Schema{type: :array, items: @system_request_schema},
connections: %Schema{type: :array, items: %Schema{
type: :object,
properties: %{
solar_system_source: %Schema{type: :integer, description: "Source system ID"},
solar_system_target: %Schema{type: :integer, description: "Target system ID"},
type: %Schema{type: :integer, description: "Connection type (default 0)"},
mass_status: %Schema{type: :integer, description: "Mass status (0-3)", nullable: true},
time_status: %Schema{type: :integer, description: "Time decay status (0-3)", nullable: true},
ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)", nullable: true},
locked: %Schema{type: :boolean, description: "Lock flag", nullable: true},
custom_info: %Schema{type: :string, description: "Optional metadata", nullable: true}
},
required: ~w(solar_system_source solar_system_target)a
}}
},
example: %{
systems: [
%{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
position_x: 100.5,
position_y: 200.3,
visible: true
}
],
connections: [
%{
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0
}
]
}
})
# -- Actions --
operation :index,
summary: "List Map Systems and Connections",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
]
],
responses: [
ok: {
"List Map Systems and Connections",
"application/json",
@list_response_schema
}
]
def index(%{assigns: %{map_id: map_id}} = conn, _params) do
systems = Operations.list_systems(map_id) |> Enum.map(&APIUtils.map_system_to_json/1)
connections = Operations.list_connections(map_id) |> Enum.map(&APIUtils.connection_to_json/1)
APIUtils.respond_data(conn, %{systems: systems, connections: connections})
end
operation :show,
summary: "Show Map System",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
],
id: [
in: :path,
description: "System ID",
type: :string,
required: true
]
],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
with {:ok, system_id} <- APIUtils.parse_int(id),
{:ok, system} <- Operations.get_system(map_id, system_id) do
APIUtils.respond_data(conn, APIUtils.map_system_to_json(system))
end
end
operation :create,
summary: "Upsert Systems and Connections (batch or single)",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
]
],
request_body: {"Systems+Connections upsert", "application/json", @batch_request_schema},
responses: ResponseSchemas.standard_responses(@batch_response_schema)
def create(conn, params) do
systems = Map.get(params, "systems", [])
connections = Map.get(params, "connections", [])
case Operations.upsert_systems_and_connections(conn, systems, connections) do
{:ok, result} ->
APIUtils.respond_data(conn, result)
error ->
error
end
end
operation :update,
summary: "Update System",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
],
id: [
in: :path,
description: "System ID",
type: :string,
required: true
]
],
request_body: {"System update request", "application/json", @system_update_schema},
responses: ResponseSchemas.update_responses(@detail_response_schema)
def update(conn, %{"id" => id} = params) do
with {:ok, sid} <- APIUtils.parse_int(id),
{:ok, attrs} <- APIUtils.extract_update_params(params),
update_attrs = Map.put(attrs, "solar_system_id", sid),
{:ok, system} <- Operations.update_system(conn, sid, update_attrs) do
APIUtils.respond_data(conn, APIUtils.map_system_to_json(system))
end
end
operation :delete,
summary: "Batch Delete Systems and Connections",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
]
],
request_body: {"Batch delete", "application/json", @batch_delete_schema},
responses: ResponseSchemas.standard_responses(@batch_delete_response_schema)
def delete(conn, params) do
system_ids = Map.get(params, "system_ids", [])
connection_ids = Map.get(params, "connection_ids", [])
deleted_systems = Enum.map(system_ids, &delete_system_id(conn, &1))
deleted_connections = Enum.map(connection_ids, &delete_connection_id(conn, &1))
systems_deleted = Enum.count(deleted_systems, &match?({:ok, _}, &1))
connections_deleted = Enum.count(deleted_connections, &match?({:ok, _}, &1))
deleted_count = systems_deleted + connections_deleted
APIUtils.respond_data(conn, %{deleted_count: deleted_count})
end
defp delete_system_id(conn, id) do
case APIUtils.parse_int(id) do
{:ok, sid} -> Operations.delete_system(conn, sid)
_ -> {:error, :invalid_id}
end
end
defp delete_connection_id(conn, id) do
case Operations.get_connection(conn, id) do
{:ok, conn_struct} ->
source_id = conn_struct.solar_system_source
target_id = conn_struct.solar_system_target
case Operations.delete_connection(conn, source_id, target_id) do
:ok -> {:ok, conn_struct}
error -> error
end
_ -> {:error, :invalid_id}
end
end
operation :delete_single,
summary: "Delete a single Map System",
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug)",
type: :string,
required: true,
example: "map-slug or map UUID"
],
id: [
in: :path,
description: "System ID",
type: :string,
required: true
]
],
responses: ResponseSchemas.standard_responses(@delete_response_schema)
def delete_single(conn, %{"id" => id}) do
with {:ok, sid} <- APIUtils.parse_int(id),
{:ok, _} <- Operations.delete_system(conn, sid) do
APIUtils.respond_data(conn, %{deleted: true})
else
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> APIUtils.respond_data(%{deleted: false, error: "System not found"})
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> APIUtils.respond_data(%{deleted: false, error: "Failed to delete system", reason: reason})
_ ->
conn
|> put_status(:bad_request)
|> APIUtils.respond_data(%{deleted: false, error: "Invalid system ID format"})
end
end
# -- Legacy endpoints --
operation :list_systems,
summary: "List Map Systems (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems instead",
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
]
],
responses: ResponseSchemas.standard_responses(@list_response_schema)
defdelegate list_systems(conn, params), to: __MODULE__, as: :index
operation :show_system,
summary: "Show Map System (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems/:id instead",
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided, but not both",
type: :string,
required: false,
],
id: [
in: :query,
description: "System ID",
type: :string,
required: true
]
],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
defdelegate show_system(conn, params), to: __MODULE__, as: :show
end