feat(Api): added map audit base API. Added comments server validations.

This commit is contained in:
Dmitry Popov
2025-03-15 09:46:18 +01:00
parent 006d10381f
commit 752eaaa0f5
26 changed files with 328 additions and 109 deletions

View File

@@ -1,30 +1,21 @@
import { Widget } from '@/hooks/Mapper/components/mapInterface/components';
import { Comments } from '@/hooks/Mapper/components/mapInterface/components/Comments';
import { SystemView } from '@/hooks/Mapper/components/ui-kit';
import { InfoDrawer, SystemView, TooltipPosition, WdImgButton } from '@/hooks/Mapper/components/ui-kit';
import { useRef } from 'react';
import useMaxWidth from '@/hooks/Mapper/hooks/useMaxWidth.ts';
import { COMPACT_MAX_WIDTH } from '@/hooks/Mapper/components/mapInterface/widgets/SystemSignatures/constants.ts';
import { useMapRootState } from '@/hooks/Mapper/mapRootProvider';
import clsx from 'clsx';
import { CommentsEditor } from '@/hooks/Mapper/components/mapInterface/components/CommentsEditor';
import { PrimeIcons } from 'primereact/api';
export const CommentsWidgetContent = () => {
const {
data: { selectedSystems, isSubscriptionActive },
data: { selectedSystems },
} = useMapRootState();
const isNotSelectedSystem = selectedSystems.length !== 1;
if (!isSubscriptionActive) {
return (
<div className="w-full h-full flex items-center justify-center">
<span className="select-none text-center text-stone-400/80 text-sm">
Comments available with &#39;Active&#39; map subscription only (contact map administrators)
</span>
</div>
);
}
if (isNotSelectedSystem) {
return (
<div className="w-full h-full flex justify-center items-center select-none text-stone-400/80 text-sm">
@@ -46,7 +37,7 @@ export const CommentsWidget = () => {
const isCompact = useMaxWidth(containerRef, COMPACT_MAX_WIDTH);
const {
data: { selectedSystems },
data: { selectedSystems, isSubscriptionActive },
} = useMapRootState();
const [systemId] = selectedSystems;
const isNotSelectedSystem = selectedSystems.length !== 1;
@@ -54,7 +45,8 @@ export const CommentsWidget = () => {
return (
<Widget
label={
<div ref={containerRef} className="flex items-center gap-1 text-xs w-full">
<div ref={containerRef} className="flex justify-between items-center gap-1 text-xs w-full">
<div className="flex items-center gap-1">
{!isCompact && (
<div className="flex whitespace-nowrap text-ellipsis overflow-hidden text-stone-400">
Comments {isNotSelectedSystem ? '' : 'in'}
@@ -62,6 +54,26 @@ export const CommentsWidget = () => {
)}
{!isNotSelectedSystem && <SystemView systemId={systemId} className="select-none text-center" hideRegion />}
</div>
<WdImgButton
className={PrimeIcons.QUESTION_CIRCLE}
tooltip={{
position: TooltipPosition.left,
content: (
<div className="flex flex-col gap-1">
<InfoDrawer title={<b className="text-slate-50">How to add/delete comment?</b>}>
It is possible to use markdown formating. <br />
Only users with tracking permission can add/delete comments. <br />
</InfoDrawer>
<InfoDrawer title={<b className="text-slate-50">Limitations</b>}>
Each comment length is limited to <b>500</b> characters. <br />
No more than <b>{isSubscriptionActive ? '500' : '30'}</b> comments are allowed per system*. <br />
<small>* based on active map subscription.</small>
</InfoDrawer>
</div>
),
}}
/>
</div>
}
>
<CommentsWidgetContent />

View File

@@ -180,7 +180,8 @@ defmodule WandererApp.Maps do
is_member_corp = to_string(c.corporation_id) in map_member_corporation_ids
is_member_alliance = to_string(c.alliance_id) in map_member_alliance_ids
has_access = is_owner or is_acl_owner or is_member_eve or is_member_corp or is_member_alliance
has_access =
is_owner or is_acl_owner or is_member_eve or is_member_corp or is_member_alliance
has_access
end)
@@ -275,8 +276,8 @@ defmodule WandererApp.Maps do
def get_system_comments_activity(system_id) do
from(sc in WandererApp.Api.MapSystemComment,
where: sc.system_id == ^system_id,
group_by: [sc.id],
select: {count(sc.id)}
group_by: [sc.system_id],
select: {count(sc.system_id)}
)
|> WandererApp.Repo.all()
end

View File

@@ -0,0 +1,172 @@
defmodule WandererAppWeb.MapAuditAPIController do
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
import Ash.Query, only: [filter: 2]
require Logger
alias WandererApp.Api
alias WandererApp.Api.Character
alias WandererApp.MapSystemRepo
alias WandererApp.MapCharacterSettingsRepo
alias WandererApp.Zkb.KillsProvider.KillsCache
alias WandererAppWeb.UtilAPIController, as: Util
# -----------------------------------------------------------------
# Inline Schemas
# -----------------------------------------------------------------
@character_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
eve_id: %OpenApiSpex.Schema{type: :string},
name: %OpenApiSpex.Schema{type: :string},
corporation_id: %OpenApiSpex.Schema{type: :string},
corporation_ticker: %OpenApiSpex.Schema{type: :string},
alliance_id: %OpenApiSpex.Schema{type: :string},
alliance_ticker: %OpenApiSpex.Schema{type: :string}
},
required: ["eve_id", "name"]
}
@map_audit_event_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
entity_type: %OpenApiSpex.Schema{type: :string},
event_name: %OpenApiSpex.Schema{type: :string},
event_data: %OpenApiSpex.Schema{type: :string},
character: @character_schema,
inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}
},
required: ["entity_type", "event_name", "event_data", "inserted_at"]
}
@map_audit_response_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
data: %OpenApiSpex.Schema{
type: :array,
items: @map_audit_event_schema
}
},
required: ["data"]
}
# -----------------------------------------------------------------
# MAP endpoints
# -----------------------------------------------------------------
@doc """
GET /api/map/audit
Requires either `?map_id=<UUID>` **OR** `?slug=<map-slug>` in the query params.
Examples:
GET /api/map/audit?map_id=466e922b-e758-485e-9b86-afae06b88363&period=1H
GET /api/map/audit?slug=my-unique-wormhole-map&period=1H
"""
@spec index(Plug.Conn.t(), map()) :: Plug.Conn.t()
operation(:index,
summary: "List Map Audit events",
description:
"Lists all audit events for a map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.",
parameters: [
map_id: [
in: :query,
description: "Map identifier (UUID) - Either map_id or slug must be provided",
type: :string,
required: false,
example: ""
],
slug: [
in: :query,
description: "Map slug - Either map_id or slug must be provided",
type: :string,
required: false,
example: "map-name"
],
period: [
in: :query,
description: "Activity period (1H, 1D, 1W, 1M, 2M, 3M)",
type: :string,
required: true,
example: "1D"
]
],
responses: [
ok: {
"List of map audit events",
"application/json",
@map_audit_response_schema
},
bad_request:
{"Error", "application/json",
%OpenApiSpex.Schema{
type: :object,
properties: %{
error: %OpenApiSpex.Schema{type: :string}
},
required: ["error"],
example: %{
"error" => "Must provide either ?map_id=UUID or ?slug=SLUG"
}
}}
]
)
def index(conn, params) do
with {:ok, map_id} <- Util.fetch_map_id(params),
{:ok, period} <- Util.require_param(params, "period"),
query <- WandererApp.Map.Audit.get_activity_query(map_id, period, "all"),
{:ok, data} <-
Api.read(query) do
data = Enum.map(data, &map_audit_event_to_json/1)
json(conn, %{data: data})
else
{:error, msg} when is_binary(msg) ->
conn
|> put_status(:bad_request)
|> json(%{error: msg})
{:error, reason} ->
conn
|> put_status(:not_found)
|> json(%{error: "Request failed: #{inspect(reason)}"})
end
end
defp map_audit_event_to_json(
%{event_type: event_type, event_data: event_data, character: character} = event
) do
# Start with the basic system data
result =
Map.take(event, [
:entity_type,
:inserted_at
])
result
|> Map.put(:character, WandererAppWeb.MapEventHandler.map_ui_character_stat(character))
|> Map.put(:event_name, WandererAppWeb.UserActivity.get_event_name(event_type))
|> Map.put(
:event_data,
WandererAppWeb.UserActivity.get_event_data(
event_type,
Jason.decode!(event_data) |> Map.drop(["character_id"])
)
)
end
defp get_original_system_name(solar_system_id) do
# Fetch the original system name from the MapSolarSystem resource
case WandererApp.Api.MapSolarSystem.by_solar_system_id(solar_system_id) do
{:ok, system} ->
system.solar_system_name
_error ->
"Unknown System"
end
end
end

View File

@@ -54,7 +54,7 @@ defmodule WandererAppWeb.UserActivity do
</p>
<p class="text-sm text-[var(--color-gray-4)] w-[15%]">
<%= get_event_name(@activity.event_type) %>
{get_event_name(@activity.event_type)}
</p>
<.activity_event event_type={@activity.event_type} event_data={@activity.event_data} />
@@ -82,7 +82,7 @@ defmodule WandererAppWeb.UserActivity do
<img src={member_icon_url(@character.eve_id)} alt={@character.name} />
</div>
</div>
<%= @character.name %>
{@character.name}
</div>
"""
end
@@ -95,7 +95,7 @@ defmodule WandererAppWeb.UserActivity do
<div class="w-[40%]">
<div class="flex items-center gap-1">
<h6 class="text-base leading-[150%] font-semibold dark:text-white">
<%= get_event_data(@event_type, Jason.decode!(@event_data) |> Map.drop(["character_id"])) %>
{get_event_data(@event_type, Jason.decode!(@event_data) |> Map.drop(["character_id"]))}
</h6>
</div>
</div>
@@ -109,26 +109,26 @@ defmodule WandererAppWeb.UserActivity do
{:noreply, socket}
end
defp get_event_name(:hub_added), do: "Hub Added"
defp get_event_name(:hub_removed), do: "Hub Removed"
defp get_event_name(:map_connection_added), do: "Connection Added"
defp get_event_name(:map_connection_updated), do: "Connection Updated"
defp get_event_name(:map_connection_removed), do: "Connection Removed"
defp get_event_name(:map_acl_added), do: "Acl Added"
defp get_event_name(:map_acl_removed), do: "Acl Removed"
defp get_event_name(:system_added), do: "System Added"
defp get_event_name(:system_updated), do: "System Updated"
defp get_event_name(:systems_removed), do: "System(s) Removed"
defp get_event_name(:signatures_added), do: "Signatures Added"
defp get_event_name(:signatures_removed), do: "Signatures Removed"
defp get_event_name(name), do: name
def get_event_name(:hub_added), do: "Hub Added"
def get_event_name(:hub_removed), do: "Hub Removed"
def get_event_name(:map_connection_added), do: "Connection Added"
def get_event_name(:map_connection_updated), do: "Connection Updated"
def get_event_name(:map_connection_removed), do: "Connection Removed"
def get_event_name(:map_acl_added), do: "Acl Added"
def get_event_name(:map_acl_removed), do: "Acl Removed"
def get_event_name(:system_added), do: "System Added"
def get_event_name(:system_updated), do: "System Updated"
def get_event_name(:systems_removed), do: "System(s) Removed"
def get_event_name(:signatures_added), do: "Signatures Added"
def get_event_name(:signatures_removed), do: "Signatures Removed"
def get_event_name(name), do: name
defp get_event_data(:map_acl_added, %{"acl_id" => acl_id}) do
def get_event_data(:map_acl_added, %{"acl_id" => acl_id}) do
{:ok, acl} = WandererApp.AccessListRepo.get(acl_id)
"#{acl.name}"
end
defp get_event_data(:map_acl_removed, %{"acl_id" => acl_id}) do
def get_event_data(:map_acl_removed, %{"acl_id" => acl_id}) do
{:ok, acl} = WandererApp.AccessListRepo.get(acl_id)
"#{acl.name}"
end
@@ -137,7 +137,7 @@ defmodule WandererAppWeb.UserActivity do
# defp get_event_data(:system_added, data), do: data
#
defp get_event_data(:system_updated, %{
def get_event_data(:system_updated, %{
"key" => "labels",
"solar_system_id" => solar_system_id,
"value" => value
@@ -154,22 +154,22 @@ defmodule WandererAppWeb.UserActivity do
end
end
defp get_event_data(:system_added, %{
def get_event_data(:system_added, %{
"solar_system_id" => solar_system_id
}),
do: get_system_name(solar_system_id)
defp get_event_data(:hub_added, %{
def get_event_data(:hub_added, %{
"solar_system_id" => solar_system_id
}),
do: get_system_name(solar_system_id)
defp get_event_data(:hub_removed, %{
def get_event_data(:hub_removed, %{
"solar_system_id" => solar_system_id
}),
do: get_system_name(solar_system_id)
defp get_event_data(:system_updated, %{
def get_event_data(:system_updated, %{
"key" => key,
"solar_system_id" => solar_system_id,
"value" => value
@@ -178,7 +178,7 @@ defmodule WandererAppWeb.UserActivity do
"#{system_name}: #{key} - #{inspect(value)}"
end
defp get_event_data(:systems_removed, %{
def get_event_data(:systems_removed, %{
"solar_system_ids" => solar_system_ids
}),
do:
@@ -186,20 +186,20 @@ defmodule WandererAppWeb.UserActivity do
|> Enum.map(&get_system_name/1)
|> Enum.join(", ")
defp get_event_data(signatures_event, %{
def get_event_data(signatures_event, %{
"solar_system_id" => solar_system_id,
"signatures" => signatures
})
when signatures_event in [:signatures_added, :signatures_removed],
do: "#{get_system_name(solar_system_id)}: #{signatures |> Enum.join(", ")}"
defp get_event_data(signatures_event, %{
def get_event_data(signatures_event, %{
"signatures" => signatures
})
when signatures_event in [:signatures_added, :signatures_removed],
do: signatures |> Enum.join(", ")
defp get_event_data(:map_connection_added, %{
def get_event_data(:map_connection_added, %{
"solar_system_source_id" => solar_system_source_id,
"solar_system_target_id" => solar_system_target_id
}) do
@@ -208,7 +208,7 @@ defmodule WandererAppWeb.UserActivity do
"[#{source_system_name}:#{target_system_name}]"
end
defp get_event_data(:map_connection_removed, %{
def get_event_data(:map_connection_removed, %{
"solar_system_source_id" => solar_system_source_id,
"solar_system_target_id" => solar_system_target_id
}) do
@@ -217,7 +217,7 @@ defmodule WandererAppWeb.UserActivity do
"[#{source_system_name}:#{target_system_name}]"
end
defp get_event_data(:map_connection_updated, %{
def get_event_data(:map_connection_updated, %{
"key" => key,
"solar_system_source_id" => solar_system_source_id,
"solar_system_target_id" => solar_system_target_id,
@@ -228,7 +228,7 @@ defmodule WandererAppWeb.UserActivity do
"[#{source_system_name}:#{target_system_name}] #{key} - #{inspect(value)}"
end
defp get_event_data(_name, data), do: Jason.encode!(data)
def get_event_data(_name, data), do: Jason.encode!(data)
defp get_system_name(solar_system_id) do
case WandererApp.CachedInfo.get_system_static_info(solar_system_id) do

View File

@@ -44,23 +44,51 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
current_user: current_user,
has_tracked_characters?: true,
map_id: map_id,
is_subscription_active?: is_subscription_active?,
tracked_character_ids: tracked_character_ids,
user_permissions: %{add_system: true}
}
} =
socket
) do
system =
WandererApp.Map.find_system_by_location(map_id, %{
solar_system_id: solar_system_id |> String.to_integer()
})
comments_count =
system.id
|> WandererApp.Maps.get_system_comments_activity()
|> case do
[{count}] when not is_nil(count) ->
count
_ ->
0
end
cond do
(is_subscription_active? && comments_count < 500) || comments_count < 30 ->
map_id
|> WandererApp.Map.Server.add_system_comment(
%{
solar_system_id: solar_system_id,
text: text
text: text |> String.slice(0..500)
},
current_user.id,
tracked_character_ids |> List.first()
)
{:noreply, socket}
true ->
{:noreply,
socket
|> Phoenix.LiveView.put_flash(
:error,
"Your reach the maximum number of comments available. Please remove some comments before adding new ones."
)}
end
end
def handle_ui_event(
@@ -116,6 +144,9 @@ defmodule WandererAppWeb.MapSystemCommentsEventHandler do
{:noreply, socket}
end
def handle_ui_event(event, body, socket),
do: MapCoreEventHandler.handle_ui_event(event, body, socket)
def map_system_comment(nil), do: nil
def map_system_comment(

View File

@@ -114,9 +114,11 @@ defmodule WandererAppWeb.Router do
http_url = URI.to_string(home_url)
# Only add script-src-elem when in development mode
script_src_elem = if(@code_reloading, do:
@script_src_values ++ [ws_url, http_url],
else: @script_src_values)
script_src_elem =
if(@code_reloading,
do: @script_src_values ++ [ws_url, http_url],
else: @script_src_values
)
directives = %{
default_src: ~w('none'),
@@ -203,6 +205,7 @@ defmodule WandererAppWeb.Router do
scope "/api/map", WandererAppWeb do
pipe_through [:api, :api_map]
get "/audit", MapAuditAPIController, :index
get "/systems", MapAPIController, :list_systems
get "/system", MapAPIController, :show_system
get "/characters", MapAPIController, :tracked_characters_with_info