Files
EventHubBack/src/handlers/handler_utils.erl
T

650 lines
25 KiB
Erlang
Executable File

%%%-------------------------------------------------------------------
%%% @doc Общие утилиты для HTTP-обработчиков.
%%% Содержит повторяющиеся функции, которые раньше копировались
%%% в каждый обработчик: аутентификация, отправка ответов,
%%% парсинг параметров, сериализация записей и генерация трейлов.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_utils).
-export([
auth_admin/1,
auth_user/1,
auth_user_optional/1,
send_json/3,
send_json/4,
send_error/3,
parse_pagination_params/1,
parse_int_qs/2,
parse_datetime_qs/1,
parse_datetime/1,
datetime_to_iso8601/1,
event_to_json/1,
booking_occupancy/1,
user_to_json/1,
review_to_json/1,
review_to_json/2,
report_to_json/1,
ticket_to_json/1,
calendar_to_json/1,
subscription_to_json/1,
booking_to_json/1,
trails_for_crud/4,
is_superadmin/1,
pagination_headers/2,
maybe_report_internal_error/3,
report_and_send_error/4
]).
-export([admin_to_json/1, audit_to_json/1]).
-include("records.hrl").
%%%===================================================================
%%% Аутентификация и авторизация
%%%===================================================================
%% @doc Проверяет, что запрос содержит валидный токен администратора.
-spec auth_admin(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
auth_admin(Req) ->
case handler_auth:authenticate(Req) of
{ok, UserId, Req1} ->
case admin_utils:is_admin(UserId) of
true -> {ok, UserId, Req1};
false -> {error, 403, <<"Admin access required">>, Req1}
end;
{error, Code, Msg, Req1} ->
{error, Code, Msg, Req1}
end.
%% @doc Проверяет, что запрос выполняет суперадмин.
-spec is_superadmin(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
is_superadmin(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
case admin_utils:is_superadmin(AdminId) of
true -> {ok, AdminId, Req1};
false -> {error, 403, <<"Only superadmin allowed">>, Req1}
end;
Error -> Error
end.
%% @doc Проверяет, что запрос содержит валидный токен пользователя.
-spec auth_user(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
auth_user(Req) ->
handler_auth:authenticate(Req).
%% @doc Как auth_user/1, но без заголовка Authorization — гость (`<<>>`).
%% Невалидный Bearer по-прежнему 401.
-spec auth_user_optional(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, integer(), binary(), cowboy_req:req()}.
auth_user_optional(Req) ->
case cowboy_req:header(<<"authorization">>, Req) of
undefined -> {ok, <<>>, Req};
<<>> -> {ok, <<>>, Req};
_ -> auth_user(Req)
end.
%%%===================================================================
%%% HTTP‑ответы
%%%===================================================================
%% @doc Отправляет JSON-ответ с указанным статусом и стандартным заголовком.
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term()) ->
{ok, binary(), cowboy_req:req()}.
send_json(Req, Status, Data) ->
send_json(Req, Status, Data, #{}).
%% @doc Отправляет JSON-ответ с указанным статусом и дополнительными заголовками.
%% ExtraHeaders вставляются поверх стандартного `content-type`.
-spec send_json(cowboy_req:req(), cowboy:http_status(), jsx:json_term(), map()) ->
{ok, binary(), cowboy_req:req()}.
send_json(Req, Status, Data, ExtraHeaders) ->
Body = jsx:encode(Data),
BaseHeaders = #{<<"content-type">> => <<"application/json">>},
Headers = maps:merge(BaseHeaders, ExtraHeaders),
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, Req1}.
%% @doc Отправляет JSON-ошибку. При Status >= 500 асинхронно регистрирует тикет.
-spec send_error(cowboy_req:req(), cowboy:http_status(), binary() | term()) ->
{ok, binary(), cowboy_req:req()}.
send_error(Req, Status, Message) when Status >= 500 ->
MsgBin = ensure_error_binary(Message),
maybe_report_internal_error(Req, MsgBin, #{}),
do_send_error(Req, Status, MsgBin);
send_error(Req, Status, Message) ->
do_send_error(Req, Status, ensure_error_binary(Message)).
%% @doc Отправить ошибку и явно зарегистрировать тикет с доп. контекстом.
-spec report_and_send_error(cowboy_req:req(), cowboy:http_status(),
binary() | term(), map()) -> {ok, binary(), cowboy_req:req()}.
report_and_send_error(Req, Status, Message, Context) when Status >= 500 ->
MsgBin = ensure_error_binary(Message),
maybe_report_internal_error(Req, MsgBin, Context),
do_send_error(Req, Status, MsgBin);
report_and_send_error(Req, Status, Message, _Context) ->
do_send_error(Req, Status, ensure_error_binary(Message)).
%% @doc Асинхронно зарегистрировать внутреннюю ошибку как тикет (backend).
-spec maybe_report_internal_error(cowboy_req:req() | map(), binary() | term(), map()) -> ok.
maybe_report_internal_error(ReqOrCtx, Message, ExtraContext) ->
case get(eventhub_reporting_ticket) of
true -> ok;
_ ->
case should_skip_ticket_report(ReqOrCtx) of
true -> ok;
false ->
MsgBin = ensure_error_binary(Message),
Context = maps:merge(request_error_context(ReqOrCtx), ExtraContext),
Stack = maps:get(<<"stacktrace">>, Context, <<>>),
Context1 = maps:remove(<<"stacktrace">>, Context),
spawn(fun() ->
try logic_ticket:report_error(backend, MsgBin, Stack, Context1)
catch
_:_ -> ok
end
end),
ok
end
end.
should_skip_ticket_report(Ctx) when is_map(Ctx) ->
Route = maps:get(<<"route">>, Ctx, maps:get(route, Ctx, <<>>)),
is_ticket_route(ensure_error_binary(Route));
should_skip_ticket_report(Req) ->
try is_ticket_route(cowboy_req:path(Req))
catch
_:_ -> false
end.
is_ticket_route(Path) when is_binary(Path) ->
binary:match(Path, <<"/tickets">>) =/= nomatch;
is_ticket_route(_) ->
false.
do_send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
Headers = #{<<"content-type">> => <<"application/json">>},
Req1 = cowboy_req:reply(Status, Headers, Body, Req),
{ok, Body, Req1}.
ensure_error_binary(V) when is_binary(V) -> V;
ensure_error_binary(V) when is_atom(V) -> atom_to_binary(V, utf8);
ensure_error_binary(V) when is_list(V) ->
try unicode:characters_to_binary(V)
catch _:_ -> list_to_binary(io_lib:format("~p", [V]))
end;
ensure_error_binary(V) ->
list_to_binary(io_lib:format("~p", [V])).
request_error_context(Ctx) when is_map(Ctx) -> Ctx;
request_error_context(Req) ->
try
Method = cowboy_req:method(Req),
Path = cowboy_req:path(Req),
#{
<<"method">> => Method,
<<"route">> => Path
}
catch
_:_ -> #{}
end.
%%%===================================================================
%%% Парсинг параметров запроса
%%%===================================================================
%% @doc Извлекает стандартные параметры пагинации/сортировки.
-spec parse_pagination_params(cowboy_req:req()) ->
#{limit => integer(), offset => integer(), sort => binary(), order => binary()}.
parse_pagination_params(Req) ->
Qs = cowboy_req:parse_qs(Req),
#{
limit => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50),
offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0),
sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>),
order => proplists:get_value(<<"order">>, Qs, <<"desc">>)
}.
-spec parse_int_qs(binary() | undefined, integer()) -> integer().
parse_int_qs(undefined, Default) -> Default;
parse_int_qs(Bin, Default) ->
try binary_to_integer(Bin) catch _:_ -> Default end.
-spec pagination_headers(map(), non_neg_integer()) -> map().
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}.
%% @doc Преобразует бинарный ISO8601 параметр в datetime().
-spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined.
parse_datetime_qs(undefined) -> undefined;
parse_datetime_qs(Bin) ->
case parse_datetime(Bin) of
{ok, Dt} -> Dt;
_ -> undefined
end.
%% @doc Разбирает ISO8601 строку в datetime().
-spec parse_datetime(binary()) -> {ok, calendar:datetime()} | {error, invalid_format}.
parse_datetime(Str) ->
try
%% Убираем завершающий 'Z', если он есть
Clean = case binary:last(Str) of
$Z -> binary_part(Str, 0, byte_size(Str) - 1);
_ -> Str
end,
%% Разделяем дату и время
[DatePart, TimePart] = binary:split(Clean, <<"T">>),
%% Парсим дату YYYY-MM-DD
[YearStr, MonthStr, DayStr] = binary:split(DatePart, <<"-">>, [global]),
%% Убираем дробные секунды, если есть
TimeMain = case binary:split(TimePart, <<".">>) of
[Main, _] -> Main;
[Main] -> Main
end,
%% Парсим время HH:MM:SS
[HourStr, MinuteStr, SecondStr] = binary:split(TimeMain, <<":">>, [global]),
Year = binary_to_integer(YearStr),
Month = binary_to_integer(MonthStr),
Day = binary_to_integer(DayStr),
Hour = binary_to_integer(HourStr),
Minute = binary_to_integer(MinuteStr),
Second = binary_to_integer(SecondStr),
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}
catch
_:_ -> {error, invalid_format}
end.
%%%===================================================================
%%% Сериализация записей (все поля согласно records.hrl)
%%%===================================================================
%% @private Преобразование записи администратора в JSON-совместимую карту.
-spec admin_to_json(#admin{}) -> map().
admin_to_json(Admin) ->
#{
id => Admin#admin.id,
email => Admin#admin.email,
role => atom_to_binary(Admin#admin.role, utf8),
status => atom_to_binary(Admin#admin.status, utf8),
nickname => Admin#admin.nickname,
avatar_url => Admin#admin.avatar_url,
timezone => Admin#admin.timezone,
language => Admin#admin.language,
phone => Admin#admin.phone,
preferences => Admin#admin.preferences,
last_login => handler_utils:datetime_to_iso8601(Admin#admin.last_login),
created_at => handler_utils:datetime_to_iso8601(Admin#admin.created_at),
updated_at => handler_utils:datetime_to_iso8601(Admin#admin.updated_at)
}.
-spec audit_to_json(#admin_audit{}) -> map().
audit_to_json(A) ->
#{
id => A#admin_audit.id,
admin_id => A#admin_audit.admin_id,
email => A#admin_audit.email,
role => A#admin_audit.role,
action => A#admin_audit.action,
entity_type => A#admin_audit.entity_type,
entity_id => audit_entity_id(A#admin_audit.entity_id),
timestamp => handler_utils:datetime_to_iso8601(A#admin_audit.timestamp),
ip => A#admin_audit.ip,
reason => audit_reason(A#admin_audit.reason)
}.
audit_entity_id(Id) when is_binary(Id) -> Id;
audit_entity_id(#{id := Id}) when is_binary(Id) -> Id;
audit_entity_id(#{<<"id">> := Id}) when is_binary(Id) -> Id;
audit_entity_id(V) ->
iolist_to_binary(io_lib:format("~p", [V])).
audit_reason(R) when is_binary(R) -> R;
audit_reason(#{reason := R}) when is_binary(R) -> R;
audit_reason(#{<<"reason">> := R}) when is_binary(R) -> R;
audit_reason(_) -> <<>>.
%% @doc Преобразует #event{} в JSON-карту.
%% Добавляет booking_occupancy: free | pending | confirmed
%% (confirmed > pending > free; cancelled/expired и past-pending → не занятость).
-spec event_to_json(#event{}) -> map().
event_to_json(Event) ->
LocationJson = case Event#event.location of
undefined -> null;
#location{address = Addr, lat = Lat, lon = Lon} ->
#{address => Addr, lat => Lat, lon => Lon}
end,
RecurrenceJson = case Event#event.recurrence_rule of
undefined -> null;
Rule -> try jsx:decode(Rule, [return_maps]) of
Map when is_map(Map) -> Map;
_ -> null
catch _:_ -> null end
end,
#{
id => Event#event.id,
calendar_id => Event#event.calendar_id,
title => Event#event.title,
description => Event#event.description,
event_type => Event#event.event_type,
start_time => datetime_to_iso8601(Event#event.start_time),
duration => Event#event.duration,
recurrence => RecurrenceJson,
master_id => Event#event.master_id,
is_instance => Event#event.is_instance,
specialist_id => Event#event.specialist_id,
location => LocationJson,
tags => Event#event.tags,
capacity => Event#event.capacity,
online_link => Event#event.online_link,
status => Event#event.status,
reason => Event#event.reason,
rating_avg => Event#event.rating_avg,
rating_count => Event#event.rating_count,
attachments => Event#event.attachments,
edit_history => Event#event.edit_history,
created_at => datetime_to_iso8601(Event#event.created_at),
updated_at => datetime_to_iso8601(Event#event.updated_at),
booking_occupancy => booking_occupancy(Event)
}.
%% @doc Активная занятость слота для сетки commercial (Studio aggregate).
%% Safe if Mnesia booking table is not ready (admin/unit tests without full schema).
-spec booking_occupancy(#event{}) -> free | pending | confirmed.
booking_occupancy(#event{id = EventId, start_time = Start}) ->
Bookings =
try
{ok, List} = core_booking:list_by_event(EventId),
List
catch
_:_ -> []
end,
Now = calendar:universal_time(),
lists:foldl(
fun(#booking{status = confirmed}, _Acc) ->
confirmed;
(#booking{status = pending}, Acc) ->
case Start > Now of
true when Acc =/= confirmed -> pending;
_ -> Acc
end;
(_, Acc) ->
Acc
end,
free,
Bookings).
%% @doc Преобразует #user{} в JSON-карту.
-spec user_to_json(#user{}) -> map().
user_to_json(User) ->
#{
id => User#user.id,
email => User#user.email,
role => User#user.role,
status => User#user.status,
reason => User#user.reason,
nickname => User#user.nickname,
avatar_url => User#user.avatar_url,
timezone => User#user.timezone,
language => User#user.language,
social_links => User#user.social_links,
phone => User#user.phone,
preferences => User#user.preferences,
last_login => datetime_to_iso8601(User#user.last_login),
created_at => datetime_to_iso8601(User#user.created_at),
updated_at => datetime_to_iso8601(User#user.updated_at)
}.
%% @doc Преобразует #review{} в JSON-карту (без my_vote).
-spec review_to_json(#review{}) -> map().
review_to_json(Review) ->
review_to_json(Review, null).
%% @doc Преобразует #review{} в JSON с голосом текущего пользователя.
-spec review_to_json(#review{}, like | dislike | null) -> map().
review_to_json(Review, MyVote) ->
#{
id => Review#review.id,
user_id => Review#review.user_id,
target_type => Review#review.target_type,
target_id => Review#review.target_id,
rating => Review#review.rating,
comment => Review#review.comment,
status => Review#review.status,
reason => Review#review.reason,
likes => Review#review.likes,
dislikes => Review#review.dislikes,
my_vote => my_vote_json(MyVote),
created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_at)
}.
my_vote_json(like) -> <<"like">>;
my_vote_json(dislike) -> <<"dislike">>;
my_vote_json(null) -> null.
%% @doc Преобразует #report{} в JSON-карту.
-spec report_to_json(#report{}) -> map().
report_to_json(Report) ->
#{
id => Report#report.id,
reporter_id => Report#report.reporter_id,
target_type => Report#report.target_type,
target_id => Report#report.target_id,
reason => Report#report.reason,
status => Report#report.status,
created_at => datetime_to_iso8601(Report#report.created_at),
resolved_at => datetime_to_iso8601(Report#report.resolved_at),
resolved_by => Report#report.resolved_by
}.
%% @doc Преобразует #ticket{} в JSON-карту.
-spec ticket_to_json(#ticket{}) -> map().
ticket_to_json(Ticket) ->
#{
id => Ticket#ticket.id,
reporter_id => Ticket#ticket.reporter_id,
error_hash => Ticket#ticket.error_hash,
error_message => Ticket#ticket.error_message,
stacktrace => Ticket#ticket.stacktrace,
context => Ticket#ticket.context,
count => Ticket#ticket.count,
first_seen => datetime_to_iso8601(Ticket#ticket.first_seen),
last_seen => datetime_to_iso8601(Ticket#ticket.last_seen),
status => Ticket#ticket.status,
assigned_to => Ticket#ticket.assigned_to,
resolution_note => Ticket#ticket.resolution_note,
closed_at => datetime_to_iso8601(Ticket#ticket.closed_at),
source => ticket_source(Ticket)
}.
ticket_source(#ticket{source = Source}) when is_binary(Source), Source =/= <<>> ->
Source;
ticket_source(_) ->
<<"backend">>.
%% @doc Преобразует #calendar{} в JSON-карту.
-spec calendar_to_json(#calendar{}) -> map().
calendar_to_json(Calendar) ->
#{
id => Calendar#calendar.id,
owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title,
description => Calendar#calendar.description,
short_name => Calendar#calendar.short_name,
category => Calendar#calendar.category,
color => Calendar#calendar.color,
image_url => Calendar#calendar.image_url,
settings => Calendar#calendar.settings,
tags => Calendar#calendar.tags,
type => Calendar#calendar.type,
confirmation => confirmation_to_json(Calendar#calendar.confirmation),
booking_open => logic_calendar:booking_open(Calendar),
rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status,
reason => Calendar#calendar.reason,
created_at => datetime_to_iso8601(Calendar#calendar.created_at),
updated_at => datetime_to_iso8601(Calendar#calendar.updated_at)
}.
confirmation_to_json(auto) -> <<"auto">>;
confirmation_to_json(manual) -> <<"manual">>;
confirmation_to_json({timeout, N}) when is_integer(N) -> #{<<"timeout">> => N};
confirmation_to_json(Other) -> Other.
%% @doc Преобразует #subscription{} в JSON-карту.
-spec subscription_to_json(#subscription{}) -> map().
subscription_to_json(Subscription) ->
#{
id => Subscription#subscription.id,
user_id => Subscription#subscription.user_id,
plan => Subscription#subscription.plan,
status => Subscription#subscription.status,
trial_used => Subscription#subscription.trial_used,
started_at => datetime_to_iso8601(Subscription#subscription.started_at),
expires_at => datetime_to_iso8601(Subscription#subscription.expires_at),
created_at => datetime_to_iso8601(Subscription#subscription.created_at),
updated_at => datetime_to_iso8601(Subscription#subscription.updated_at)
}.
%% @doc Booking JSON with optional booker nickname/email for owner UI.
-spec booking_to_json(#booking{}) -> map().
booking_to_json(Booking) ->
Base = #{
id => Booking#booking.id,
event_id => Booking#booking.event_id,
user_id => Booking#booking.user_id,
status => Booking#booking.status,
notes => Booking#booking.notes,
reminder_sent => Booking#booking.reminder_sent,
confirmed_at => case Booking#booking.confirmed_at of
undefined -> null;
Dt -> datetime_to_iso8601(Dt)
end,
created_at => datetime_to_iso8601(Booking#booking.created_at),
updated_at => datetime_to_iso8601(Booking#booking.updated_at)
},
maps:merge(Base, booking_user_fields(Booking#booking.user_id)).
%% @private
booking_user_fields(UserId) when is_binary(UserId) ->
case core_user:get_by_id(UserId) of
{ok, #user{nickname = Nick, email = Email}} ->
#{
user_nickname => empty_to_null(Nick),
user_email => empty_to_null(Email)
};
_ ->
#{user_nickname => null, user_email => null}
end;
booking_user_fields(_) ->
#{user_nickname => null, user_email => null}.
empty_to_null(undefined) -> null;
empty_to_null(null) -> null;
empty_to_null(<<>>) -> null;
empty_to_null(V) -> V.
%%%===================================================================
%%% Вспомогательные внутренние функции
%%%===================================================================
%% @private
-spec datetime_to_iso8601(calendar:datetime() | binary() | undefined) -> binary() | undefined.
datetime_to_iso8601(undefined) -> undefined;
datetime_to_iso8601(Bin) when is_binary(Bin) ->
case byte_size(Bin) > 0 of
true -> Bin;
false -> undefined
end;
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(
io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])).
%%%===================================================================
%%% Генерация Swagger-трейлов для типового CRUD-ресурса
%%%===================================================================
%% @doc Генерирует трейлы для GET (list), GET /:id, POST, PUT, DELETE.
-spec trails_for_crud(binary(), binary(), map(), map()) -> [map()].
trails_for_crud(Path, _Resource, GetSchema, UpdateSchema) ->
IdParam = #{
name => <<"id">>,
in => <<"path">>,
description => <<"Resource ID">>,
required => true,
schema => #{type => string}
},
[
#{ % GET list
path => Path,
method => <<"GET">>,
description => <<"List all records">>,
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 records">>,
content => #{<<"application/json">> => #{schema => GetSchema}}
}
}
},
#{ % GET by id
path => <<Path/binary, "/:id">>,
method => <<"GET">>,
description => <<"Get record by ID">>,
parameters => [IdParam],
responses => #{
200 => #{
description => <<"Record details">>,
content => #{<<"application/json">> => #{schema => GetSchema}}
}
}
},
#{ % POST
path => Path,
method => <<"POST">>,
description => <<"Create a new record">>,
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => UpdateSchema}}
},
responses => #{
201 => #{description => <<"Record created">>}
}
},
#{ % PUT
path => <<Path/binary, "/:id">>,
method => <<"PUT">>,
description => <<"Update record by ID">>,
parameters => [IdParam],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => UpdateSchema}}
},
responses => #{
200 => #{description => <<"Record updated">>}
}
},
#{ % DELETE
path => <<Path/binary, "/:id">>,
method => <<"DELETE">>,
description => <<"Delete record by ID">>,
parameters => [IdParam],
responses => #{
200 => #{description => <<"Record deleted">>}
}
}
].