650 lines
25 KiB
Erlang
Executable File
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">>}
|
|
}
|
|
}
|
|
]. |