%%%------------------------------------------------------------------- %%% @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 => <>, 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 => <>, method => <<"PUT">>, description => <<"Update record by ID">>, parameters => [IdParam], requestBody => #{ required => true, content => #{<<"application/json">> => #{schema => UpdateSchema}} }, responses => #{ 200 => #{description => <<"Record updated">>} } }, #{ % DELETE path => <>, method => <<"DELETE">>, description => <<"Delete record by ID">>, parameters => [IdParam], responses => #{ 200 => #{description => <<"Record deleted">>} } } ].