Files
EventHubBack/src/handlers/handler_utils.erl

405 lines
16 KiB
Erlang
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
%%%-------------------------------------------------------------------
%%% @doc Общие утилиты для HTTP-обработчиков.
%%% Содержит повторяющиеся функции, которые раньше копировались
%%% в каждый обработчик: аутентификация, отправка ответов,
%%% парсинг параметров, сериализация записей и генерация трейлов.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_utils).
-export([
auth_admin/1,
auth_user/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,
user_to_json/1,
review_to_json/1,
report_to_json/1,
ticket_to_json/1,
calendar_to_json/1,
subscription_to_json/1,
trails_for_crud/4,
is_superadmin/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).
%%%===================================================================
%%% 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-ошибку.
-spec send_error(cowboy_req:req(), cowboy:http_status(), binary()) ->
{ok, binary(), cowboy_req:req()}.
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}.
%%%===================================================================
%%% Парсинг параметров запроса
%%%===================================================================
%% @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.
%% @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)
%%%===================================================================
%% @doc Преобразует #event{} в JSON-карту.
-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)
}.
%% @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-карту.
-spec review_to_json(#review{}) -> map().
review_to_json(Review) ->
#{
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,
created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_at)
}.
%% @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
}.
%% @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 => Calendar#calendar.confirmation,
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)
}.
%% @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)
}.
%%%===================================================================
%%% Вспомогательные внутренние функции
%%%===================================================================
%% @private
-spec datetime_to_iso8601(calendar:datetime() | undefined) -> binary() | undefined.
datetime_to_iso8601(undefined) -> undefined;
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">>}
}
}
].