167 lines
6.4 KiB
Erlang
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}.
|