%%%------------------------------------------------------------------- %%% @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}.