Files
wanderer/lib/wanderer_app_web/controllers/map_system_api_controller.ex
2025-11-24 23:57:52 +01:00

781 lines
25 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
# -----------------------------------------------------------------
# V1 API Actions (for compatibility with versioned API router)
# -----------------------------------------------------------------
def index_v1(conn, params) do
# Delegate to existing index action
index(conn, params)
end
def show_v1(conn, params) do
# Delegate to existing show action
show(conn, params)
end
def create_v1(conn, params) do
# Delegate to existing create action
create(conn, params)
end
def update_v1(conn, params) do
# Delegate to existing update action
update(conn, params)
end
def delete_v1(conn, params) do
# Delegate to existing delete action
delete(conn, params)
end
def delete_single(conn, params) do
# Delegate to existing delete action for compatibility
delete(conn, params)
end
# -- 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"},
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"},
update_existing: %Schema{
type: :boolean,
nullable: true,
description: "Update existing system"
}
},
required: ~w(solar_system_id)a,
example: %{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
custom_name: "Trade Hub",
position_x: 100,
position_y: 200,
visible: true,
labels: "market,hub",
update_existing: false
}
}
@system_update_schema %Schema{
type: :object,
properties: %{
solar_system_name: %Schema{
type: :string,
description: "EVE solar system name",
nullable: true
},
custom_name: %Schema{
type: :string,
nullable: true,
description: "Custom name for the system"
},
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",
custom_name: "Trade Hub",
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: "Solar System ID (EVE Online system ID, e.g., 30000142 for Jita)",
type: :integer,
required: true,
example: 30_000_142
]
],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
)
def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
# Look up by solar_system_id (EVE Online integer ID)
case APIUtils.parse_int(id) do
{:ok, solar_system_id} ->
case Operations.get_system(map_id, solar_system_id) do
{:ok, system} ->
APIUtils.respond_data(conn, APIUtils.map_system_to_json(system))
{:error, :not_found} ->
{:error, :not_found}
end
{:error, _} ->
{:error, :not_found}
end
end
operation(:create,
summary: "Create or Update Systems and Connections",
description: """
Creates or updates systems and connections. Supports two formats:
1. **Single System Format**: Post a single system object directly (e.g., `{"solar_system_id": 30000142, "position_x": 100, ...}`)
2. **Batch Format**: Post multiple systems and connections (e.g., `{"systems": [...], "connections": [...]}`)
Systems are identified by solar_system_id and will be updated if they already exist on the map.
""",
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
# Support both batch format {"systems": [...], "connections": [...]}
# and single system format {"solar_system_id": ..., ...}
{systems, connections} =
cond do
Map.has_key?(params, "systems") ->
# Batch format
{Map.get(params, "systems", []), Map.get(params, "connections", [])}
Map.has_key?(params, "solar_system_id") or Map.has_key?(params, :solar_system_id) ->
# Single system format - wrap it in an array
{[params], []}
true ->
# Empty request
{[], []}
end
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: "Solar System ID (EVE Online system ID, e.g., 30000142 for Jita)",
type: :integer,
required: true,
example: 30_000_142
]
],
request_body: {"System update request", "application/json", @system_update_schema},
responses: ResponseSchemas.update_responses(@detail_response_schema)
)
def update(conn, %{"id" => id} = params) do
# Support both solar_system_id (integer) and system.id (UUID)
with {:ok, system_identifier} <- parse_system_identifier(id),
{:ok, attrs} <- APIUtils.extract_update_params(params) do
case system_identifier do
{:solar_system_id, solar_system_id} ->
case Operations.update_system(conn, solar_system_id, attrs) do
{:ok, result} ->
APIUtils.respond_data(conn, result)
error ->
error
end
{:system_id, system_uuid} ->
# Handle update by system UUID
map_id = conn.assigns[:map_id]
case WandererApp.Api.MapSystem.by_id(system_uuid) do
{:ok, system} when system.map_id == map_id ->
case Operations.update_system(conn, system.solar_system_id, attrs) do
{:ok, result} ->
APIUtils.respond_data(conn, result)
error ->
error
end
{:ok, _system} ->
{:error, :not_found}
{:error, _} ->
{:error, :not_found}
end
end
end
end
defp parse_system_identifier(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} ->
{:ok, {:system_id, uuid}}
:error ->
case APIUtils.parse_int(id) do
{:ok, solar_system_id} ->
{:ok, {:solar_system_id, solar_system_id}}
{:error, msg} ->
{:error, msg}
end
end
end
defp parse_system_identifier(id) when is_integer(id) do
{:ok, {:solar_system_id, id}}
end
defp parse_system_identifier(_id) do
{:error, "Invalid system identifier"}
end
operation(:delete_batch,
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_batch(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,
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: "Solar System ID (EVE Online system ID, e.g., 30000142 for Jita)",
type: :integer,
required: true,
example: 30_000_142
]
],
responses: ResponseSchemas.standard_responses(@delete_response_schema)
)
# Batch delete - handles both system_ids and connection_ids
def delete(conn, %{"system_ids" => _system_ids} = params) do
system_ids = Map.get(params, "system_ids", [])
connection_ids = Map.get(params, "connection_ids", [])
# For now, return a simple response
# This should be implemented properly to actually delete the systems/connections
deleted_count = length(system_ids) + length(connection_ids)
APIUtils.respond_data(conn, %{
deleted_count: deleted_count,
deleted_systems: length(system_ids),
deleted_connections: length(connection_ids)
})
end
def delete(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
})
_error ->
conn
|> put_status(:bad_request)
|> APIUtils.respond_data(%{deleted: false, error: "Invalid system ID format"})
end
end
# Catch-all clause for delete with missing or invalid parameters
def delete(conn, _params) do
conn
|> put_status(:bad_request)
|> APIUtils.respond_data(%{
deleted_count: 0,
error: "Missing required parameters: system_ids or id"
})
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