Files
EventHubBack/src/handlers/handler_tickets.erl
T
aleksey a696d25486
CI / test (push) Successful in 21m45s
CI / deploy-ift (push) Successful in 6m8s
CI / e2e-ift (push) Failing after 6m13s
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
Добавить авто-регистрацию тикетов (hash/dedupe/500/WS). Refs EventHub/EventHubBack#35
2026-07-17 00:44:09 +03:00

167 lines
6.4 KiB
Erlang

%%%-------------------------------------------------------------------
%%% @doc Обработчик пользовательских тикетов (клиентский API).
%%%
%%% GET – получить список тикетов.
%%% Администраторы видят все тикеты,
%%% обычные пользователи – только свои.
%%% POST – создать/дедуплицировать тикет об ошибке через logic_ticket:report_error.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_tickets).
-behaviour(cowboy_handler).
-export([init/2]).
-export([trails/0]).
-include("records.hrl").
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % GET list
path => <<"/v1/tickets">>,
method => <<"GET">>,
description => <<"List tickets (admin sees all, user sees own)">>,
tags => [<<"Tickets">>],
parameters => [
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
responses => #{
200 => #{
description => <<"Array of tickets">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => ticket_schema()
}}}
},
401 => #{description => <<"Unauthorized">>}
}
},
#{ % POST create
path => <<"/v1/tickets">>,
method => <<"POST">>,
description => <<"Create or bump a ticket (bug report / frontend error)">>,
tags => [<<"Tickets">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"error_message">>],
properties => #{
error_message => #{type => string},
stacktrace => #{type => string},
context => #{type => object},
source => #{type => string, enum => [<<"frontend">>, <<"manual">>]}
}
}}}
},
responses => #{
201 => #{description => <<"Ticket created or updated">>},
400 => #{description => <<"Missing required fields or invalid JSON">>},
401 => #{description => <<"Unauthorized">>},
429 => #{description => <<"Rate limited">>}
}
}
].
ticket_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
reporter_id => #{type => string},
error_hash => #{type => string},
error_message => #{type => string},
stacktrace => #{type => string},
context => #{type => string},
count => #{type => integer},
first_seen => #{type => string, format => <<"date-time">>},
last_seen => #{type => string, format => <<"date-time">>},
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string, nullable => true},
resolution_note => #{type => string, nullable => true},
source => #{type => string, enum => [<<"backend">>, <<"frontend">>, <<"manual">>]}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"GET">> -> list_tickets(Req);
<<"POST">> -> create_ticket(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
%% @doc GET /v1/tickets — список тикетов.
-spec list_tickets(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_tickets(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case admin_utils:is_admin(UserId) of
true ->
Tickets = core_ticket:list_all(),
handler_utils:send_json(Req1, 200, [handler_utils:ticket_to_json(T) || T <- Tickets]);
false ->
Tickets = core_ticket:list_by_user(UserId),
handler_utils:send_json(Req1, 200, [handler_utils:ticket_to_json(T) || T <- Tickets])
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
%% @doc POST /v1/tickets — создание/дедуп тикета.
-spec create_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_ticket(Req) ->
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
#{<<"error_message">> := ErrorMessage} = Data ->
Stacktrace = maps:get(<<"stacktrace">>, Data, <<>>),
Source = resolve_source(Data, Stacktrace),
Context0 = maps:get(<<"context">>, Data, #{}),
Context = merge_context(Context0, UserId),
case logic_ticket:report_error(Source, ErrorMessage, Stacktrace, Context) of
{ok, Ticket} ->
handler_utils:send_json(Req2, 201, handler_utils:ticket_to_json(Ticket));
{error, rate_limited} ->
handler_utils:send_error(Req2, 429, <<"Too many new tickets">>);
{error, Reason} ->
handler_utils:send_error(Req2, 500, Reason)
end;
_ ->
handler_utils:send_error(Req2, 400, <<"Missing 'error_message' field">>)
catch
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
resolve_source(#{<<"source">> := <<"frontend">>}, _) -> frontend;
resolve_source(#{<<"source">> := <<"manual">>}, _) -> manual;
resolve_source(#{<<"source">> := <<"backend">>}, _) -> backend;
resolve_source(_, Stacktrace) when is_binary(Stacktrace), Stacktrace =/= <<>> ->
frontend;
resolve_source(_, _) ->
manual.
merge_context(Ctx, UserId) when is_map(Ctx) ->
Ctx#{<<"reporter_id">> => UserId};
merge_context(Ctx, UserId) when is_binary(Ctx) ->
#{<<"reporter_id">> => UserId, <<"raw">> => Ctx};
merge_context(_, UserId) ->
#{<<"reporter_id">> => UserId}.