Рефакторинг обработчиков. Часть 2 #21

This commit is contained in:
2026-05-11 21:51:45 +03:00
parent 6403f061df
commit 61bb44ab4a
31 changed files with 8391 additions and 1480 deletions

View File

@@ -9,14 +9,14 @@
password_hash :: binary(), password_hash :: binary(),
role :: user | bot, role :: user | bot,
status :: active | frozen | deleted, status :: active | frozen | deleted,
reason :: binary() | undefined, reason :: binary(),
nickname :: binary() | undefined, nickname :: binary(),
avatar_url :: binary() | undefined, avatar_url :: binary() | default,
timezone :: binary() | undefined, timezone :: binary(),
language :: binary() | undefined, language :: binary(),
social_links :: [binary()] | undefined, social_links :: [binary()],
phone :: binary() | undefined, phone :: binary(),
preferences :: map() | undefined, preferences :: map(),
last_login :: calendar:datetime(), last_login :: calendar:datetime(),
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
@@ -36,12 +36,12 @@
password_hash :: binary(), password_hash :: binary(),
role :: superadmin | admin | moderator | support, role :: superadmin | admin | moderator | support,
status :: active | blocked, status :: active | blocked,
nickname :: binary() | undefined, nickname :: binary(),
avatar_url :: binary() | undefined, avatar_url :: binary() | default,
timezone :: binary() | undefined, timezone :: binary(),
language :: binary() | undefined, language :: binary(),
phone :: binary() | undefined, phone :: binary(),
preferences :: map() | undefined, preferences :: map(),
last_login :: calendar:datetime(), last_login :: calendar:datetime(),
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
@@ -61,18 +61,18 @@
owner_id :: binary(), owner_id :: binary(),
title :: binary(), title :: binary(),
description :: binary(), description :: binary(),
short_name :: binary() | undefined, short_name :: binary(),
category :: binary() | undefined, category :: binary(),
color :: binary() | undefined, color :: binary(),
image_url :: binary() | undefined, image_url :: binary(),
settings :: map() | undefined, settings :: map(),
tags :: [binary()], tags :: [binary()],
type :: personal | commercial, type :: personal | commercial,
confirmation :: auto | manual | {timeout, integer()}, % секунд confirmation :: auto | manual | {timeout, integer()}, % секунд
rating_avg :: float(), rating_avg :: float(),
rating_count :: non_neg_integer(), rating_count :: non_neg_integer(),
status :: active | frozen | deleted, status :: active | frozen | deleted,
reason :: binary() | undefined, reason :: binary(),
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
}). }).
@@ -88,7 +88,7 @@
calendar_id :: binary(), calendar_id :: binary(),
user_id :: binary(), % id пользователя-специалиста user_id :: binary(), % id пользователя-специалиста
name :: binary(), % отображаемое имя в этом календаре name :: binary(), % отображаемое имя в этом календаре
specialization :: [binary()] | undefined, % список специализаций (услуг) specialization :: [binary()], % список специализаций (услуг)
status :: active | inactive, status :: active | inactive,
added_at :: calendar:datetime(), added_at :: calendar:datetime(),
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
@@ -109,20 +109,20 @@
event_type :: single | recurring, event_type :: single | recurring,
start_time :: calendar:datetime(), start_time :: calendar:datetime(),
duration :: integer(), % минуты duration :: integer(), % минуты
recurrence_rule :: binary() | undefined, recurrence_rule :: binary(),
master_id :: binary() | undefined, master_id :: binary(),
is_instance :: boolean(), is_instance :: boolean(),
specialist_id :: binary() | undefined, specialist_id :: binary(),
location :: #location{} | undefined, location :: #location{},
tags :: [binary()], tags :: [binary()],
capacity :: integer() | undefined, capacity :: integer(),
online_link :: binary() | undefined, online_link :: binary(),
status :: active | cancelled | completed, status :: active | cancelled | completed,
reason :: binary() | undefined, reason :: binary(),
rating_avg :: float(), rating_avg :: float(),
rating_count :: non_neg_integer(), rating_count :: non_neg_integer(),
attachments :: [binary()] | undefined, attachments :: [binary()],
edit_history :: [map()] | undefined, edit_history :: [map()],
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
}). }).
@@ -131,7 +131,7 @@
master_id :: binary(), master_id :: binary(),
original_start :: calendar:datetime(), original_start :: calendar:datetime(),
action :: cancel | reschedule, action :: cancel | reschedule,
new_start :: calendar:datetime() | undefined new_start :: calendar:datetime()
}). }).
%% ------------------- Бронирования ------------------------------------ %% ------------------- Бронирования ------------------------------------
@@ -140,9 +140,9 @@
event_id :: binary(), % ссылка на конкретный экземпляр события event_id :: binary(), % ссылка на конкретный экземпляр события
user_id :: binary(), user_id :: binary(),
status :: pending | confirmed | cancelled, status :: pending | confirmed | cancelled,
notes :: binary() | undefined, notes :: binary(),
reminder_sent :: boolean(), reminder_sent :: boolean(),
confirmed_at :: calendar:datetime() | undefined, confirmed_at :: calendar:datetime(),
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
updated_at :: calendar:datetime() updated_at :: calendar:datetime()
}). }).
@@ -156,7 +156,7 @@
rating :: 1..5, rating :: 1..5,
comment :: binary(), comment :: binary(),
status :: visible | hidden | deleted, status :: visible | hidden | deleted,
reason :: binary() | undefined, reason :: binary(),
likes :: non_neg_integer(), likes :: non_neg_integer(),
dislikes :: non_neg_integer(), dislikes :: non_neg_integer(),
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
@@ -172,15 +172,15 @@
reason :: binary(), reason :: binary(),
status :: pending | reviewed | dismissed, status :: pending | reviewed | dismissed,
created_at :: calendar:datetime(), created_at :: calendar:datetime(),
resolved_at :: calendar:datetime() | undefined, resolved_at :: calendar:datetime(),
resolved_by :: binary() | undefined resolved_by :: binary()
}). }).
-record(banned_word, { -record(banned_word, {
id :: binary(), id :: binary(),
word :: binary(), word :: binary(),
added_by :: binary() | undefined, % id администратора, добавившего слово added_by :: binary(), % id администратора, добавившего слово
added_at :: calendar:datetime() | undefined added_at :: calendar:datetime()
}). }).
%% ------------------- Баг-трекер -------------------------------------- %% ------------------- Баг-трекер --------------------------------------
@@ -195,8 +195,8 @@
first_seen :: calendar:datetime(), first_seen :: calendar:datetime(),
last_seen :: calendar:datetime(), last_seen :: calendar:datetime(),
status :: open | in_progress | resolved | closed, status :: open | in_progress | resolved | closed,
assigned_to :: binary() | undefined, assigned_to :: binary(),
resolution_note :: binary() | undefined resolution_note :: binary()
}). }).
%% ------------------- Подписки ---------------------------------------- %% ------------------- Подписки ----------------------------------------
@@ -223,10 +223,10 @@
entity_id :: binary(), entity_id :: binary(),
timestamp :: calendar:datetime(), timestamp :: calendar:datetime(),
ip :: binary(), ip :: binary(),
reason :: binary() | undefined reason :: binary()
}). }).
%% ------------------- Уведомления (задача #12) ------------------------ %% ------------------- Уведомления ------------------------
-record(notification, { -record(notification, {
id :: binary(), id :: binary(),
user_id :: binary(), user_id :: binary(),

View File

@@ -1,24 +1,51 @@
%%%-------------------------------------------------------------------
%%% @doc Единый модуль аутентификации запросов.
%%% Извлекает JWT из заголовка `Authorization: Bearer <token>`,
%%% проверяет его и возвращает `{ok, UserId, Req}` или ошибку.
%%% Используется модулем `handler_utils` и другими обработчиками.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_auth). -module(handler_auth).
-export([authenticate/1]). -export([authenticate/1]).
%%%===================================================================
%%% API
%%%===================================================================
%% @doc Извлекает и проверяет Bearer token из запроса Cowboy.
%%
%% Возвращает:
%% <ul>
%% <li>`{ok, UserId, Req}' токен валиден</li>
%% <li>`{error, 401, Message, Req}' токен отсутствует, истёк или невалиден</li>
%% </ul>
-spec authenticate(cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, 401, binary(), cowboy_req:req()}.
authenticate(Req) -> authenticate(Req) ->
io:format("[AUTH] Starting authentication...~n"),
case cowboy_req:parse_header(<<"authorization">>, Req) of case cowboy_req:parse_header(<<"authorization">>, Req) of
{bearer, Token} -> {bearer, Token} ->
io:format("[AUTH] Bearer token found: ~s...~n", [binary_part(Token, 0, 30)]), verify_token(Token, Req);
case logic_auth:verify_jwt(Token) of _ ->
{ok, UserId, _Role} -> io:format("[AUTH] No bearer token~n"),
io:format("[AUTH] JWT verified, UserId: ~s~n", [UserId]),
{ok, UserId, Req};
{error, expired} ->
io:format("[AUTH] JWT expired~n"),
{error, 401, <<"Token expired">>, Req};
{error, Reason} ->
io:format("[AUTH] JWT invalid: ~p~n", [Reason]),
{error, 401, <<"Invalid token">>, Req}
end;
Other ->
io:format("[AUTH] No bearer token: ~p~n", [Other]),
{error, 401, <<"Missing or invalid Authorization header">>, Req} {error, 401, <<"Missing or invalid Authorization header">>, Req}
end.
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Проверяет переданный JWT-токен.
-spec verify_token(binary(), cowboy_req:req()) ->
{ok, binary(), cowboy_req:req()} | {error, 401, binary(), cowboy_req:req()}.
verify_token(Token, Req) ->
case logic_auth:verify_jwt(Token) of
{ok, UserId, _Role} ->
{ok, UserId, Req};
{error, expired} ->
io:format("[AUTH] JWT expired~n"),
{error, 401, <<"Token expired">>, Req};
{error, _Reason} ->
io:format("[AUTH] JWT invalid~n"),
{error, 401, <<"Invalid token">>, Req}
end. end.

View File

@@ -1,130 +1,196 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик конкретного бронирования (клиентский API).
%%%
%%% GET получить информацию о бронировании.
%%% PUT подтвердить или отклонить бронирование (владельцем).
%%% DELETE отменить бронирование (участником).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_booking_by_id). -module(handler_booking_by_id).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
init(Req, Opts) -> -include("records.hrl").
handle(Req, Opts).
handle(Req, _Opts) -> %%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_booking(Req); <<"GET">> -> get_booking(Req);
<<"PUT">> -> update_booking(Req); <<"PUT">> -> update_booking(Req);
<<"DELETE">> -> cancel_booking(Req); <<"DELETE">> -> cancel_booking(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/bookings/:id - получение бронирования %%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BaseParams = [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Booking ID">>,
required => true,
schema => #{type => string}
}
],
[
#{ % GET by id
path => <<"/v1/bookings/:id">>,
method => <<"GET">>,
description => <<"Get booking by ID">>,
tags => [<<"Bookings">>],
parameters => BaseParams,
responses => #{
200 => #{
description => <<"Booking details">>,
content => #{<<"application/json">> => #{schema => booking_schema()}}
},
404 => #{description => <<"Booking not found">>}
}
},
#{ % PUT update (confirm/decline)
path => <<"/v1/bookings/:id">>,
method => <<"PUT">>,
description => <<"Confirm or decline a booking (owner)">>,
tags => [<<"Bookings">>],
parameters => BaseParams,
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => booking_update_schema()}}
},
responses => #{
200 => #{description => <<"Booking updated">>},
400 => #{description => <<"Invalid action">>},
404 => #{description => <<"Booking not found">>}
}
},
#{ % DELETE cancel
path => <<"/v1/bookings/:id">>,
method => <<"DELETE">>,
description => <<"Cancel booking (participant)">>,
tags => [<<"Bookings">>],
parameters => BaseParams,
responses => #{
200 => #{description => <<"Booking cancelled">>},
404 => #{description => <<"Booking not found">>}
}
}
].
booking_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
booking_update_schema() ->
#{
type => object,
required => [<<"action">>],
properties => #{
action => #{type => string, enum => [<<"confirm">>, <<"decline">>]}
}
}.
%%% Internal functions
%% @doc Получить бронирование по ID.
-spec get_booking(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_booking(Req) -> get_booking(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
BookingId = cowboy_req:binding(id, Req1), BookingId = cowboy_req:binding(id, Req1),
case logic_booking:get_booking(UserId, BookingId) of case logic_booking:get_booking(UserId, BookingId) of
{ok, Booking} -> {ok, Booking} ->
Response = booking_to_json(Booking), handler_utils:send_json(Req1, 200, booking_to_json(Booking));
send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Booking not found">>); handler_utils:send_error(Req1, 404, <<"Booking not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% PUT /v1/bookings/:id - подтверждение/отклонение бронирования (владельцем) %% @doc Подтвердить или отклонить бронирование (владельцем).
-spec update_booking(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_booking(Req) -> update_booking(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
BookingId = cowboy_req:binding(id, Req1), BookingId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) -> #{<<"action">> := Action} when Action =:= <<"confirm">>; Action =:= <<"decline">> ->
case maps:get(<<"action">>, Decoded, undefined) of ActionAtom = binary_to_existing_atom(Action, utf8),
<<"confirm">> -> case logic_booking:confirm_booking(UserId, BookingId, ActionAtom) of
case logic_booking:confirm_booking(UserId, BookingId, confirm) of {ok, Booking} ->
{ok, Booking} -> handler_utils:send_json(Req2, 200, booking_to_json(Booking));
Response = booking_to_json(Booking), {error, access_denied} ->
send_json(Req2, 200, Response); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, access_denied} -> {error, not_found} ->
send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 404, <<"Booking not found">>);
{error, not_found} -> {error, _} ->
send_error(Req2, 404, <<"Booking not found">>); handler_utils:send_error(Req2, 500, <<"Internal server error">>)
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
<<"decline">> ->
case logic_booking:confirm_booking(UserId, BookingId, decline) of
{ok, Booking} ->
Response = booking_to_json(Booking),
send_json(Req2, 200, Response);
{error, access_denied} ->
send_error(Req2, 403, <<"Access denied">>);
{error, not_found} ->
send_error(Req2, 404, <<"Booking not found">>);
{error, _} ->
send_error(Req2, 500, <<"Internal server error">>)
end;
_ ->
send_error(Req2, 400, <<"Missing or invalid 'action' field. Use 'confirm' or 'decline'">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Missing or invalid 'action' field. Use 'confirm' or 'decline'">>)
catch catch
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% DELETE /v1/bookings/:id - отмена бронирования (участником) %% @doc Отменить бронирование (участником).
-spec cancel_booking(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
cancel_booking(Req) -> cancel_booking(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
BookingId = cowboy_req:binding(id, Req1), BookingId = cowboy_req:binding(id, Req1),
case logic_booking:cancel_booking(UserId, BookingId) of case logic_booking:cancel_booking(UserId, BookingId) of
{ok, Booking} -> {ok, Booking} ->
Response = booking_to_json(Booking), handler_utils:send_json(Req1, 200, booking_to_json(Booking));
send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Booking not found">>); handler_utils:send_error(Req1, 404, <<"Booking not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %% @private Формирует JSON-представление записи #booking{}.
%% Учитывает все поля из records.hrl.
-spec booking_to_json(#booking{}) -> map().
booking_to_json(Booking) -> booking_to_json(Booking) ->
#{ #{
id => Booking#booking.id, id => Booking#booking.id,
event_id => Booking#booking.event_id, event_id => Booking#booking.event_id,
user_id => Booking#booking.user_id, user_id => Booking#booking.user_id,
status => Booking#booking.status, status => Booking#booking.status,
confirmed_at => case Booking#booking.confirmed_at of notes => Booking#booking.notes,
undefined -> null; reminder_sent => Booking#booking.reminder_sent,
Dt -> datetime_to_iso8601(Dt) confirmed_at => case Booking#booking.confirmed_at of
end, undefined -> null;
created_at => datetime_to_iso8601(Booking#booking.created_at), Dt -> handler_utils:datetime_to_iso8601(Dt)
updated_at => datetime_to_iso8601(Booking#booking.updated_at) end,
}. created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,89 +1,175 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик маршрута `/v1/events/:id/bookings`.
%%%
%%% POST Создание бронирования (запись на событие).
%%% GET Получение списка бронирований события (для владельца календаря).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_bookings). -module(handler_bookings).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"POST">> -> create_booking(Req); <<"POST">> -> create_booking(Req);
<<"GET">> -> list_bookings(Req); <<"GET">> -> list_bookings(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% POST /v1/events/:id/bookings - создание бронирования (запись на событие) %%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BookingSchema = booking_schema(),
[
#{ % POST
path => <<"/v1/events/:id/bookings">>,
method => <<"POST">>,
description => <<"Create a booking for an event">>,
tags => [<<"Bookings">>],
parameters => [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Event ID">>,
required => true,
schema => #{type => string}
}
],
responses => #{
201 => #{
description => <<"Booking created">>,
content => #{<<"application/json">> => #{schema => BookingSchema}}
},
400 => #{description => <<"Event is full or not active">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>},
409 => #{description => <<"Already booked">>}
}
},
#{ % GET list
path => <<"/v1/events/:id/bookings">>,
method => <<"GET">>,
description => <<"List bookings for an event (owner only)">>,
tags => [<<"Bookings">>],
parameters => [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Event ID">>,
required => true,
schema => #{type => string}
}
],
responses => #{
200 => #{
description => <<"Array of bookings">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => BookingSchema
}}}
},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>}
}
}
].
-spec booking_schema() -> map().
booking_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @doc POST /v1/events/:id/bookings — Создание бронирования.
-spec create_booking(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_booking(Req) -> create_booking(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
case logic_booking:create_booking(UserId, EventId) of case logic_booking:create_booking(UserId, EventId) of
{ok, Booking} -> {ok, Booking} ->
Response = booking_to_json(Booking), handler_utils:send_json(Req1, 201, booking_to_json(Booking));
send_json(Req1, 201, Response);
{error, already_booked} -> {error, already_booked} ->
send_error(Req1, 409, <<"Already booked">>); handler_utils:send_error(Req1, 409, <<"Already booked">>);
{error, event_full} -> {error, event_full} ->
send_error(Req1, 400, <<"Event is full">>); handler_utils:send_error(Req1, 400, <<"Event is full">>);
{error, event_not_active} -> {error, event_not_active} ->
send_error(Req1, 400, <<"Event is not active">>); handler_utils:send_error(Req1, 400, <<"Event is not active">>);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% GET /v1/events/:id/bookings - список бронирований события (для владельца) %% @doc GET /v1/events/:id/bookings Список бронирований события (для владельца).
-spec list_bookings(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_bookings(Req) -> list_bookings(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
case logic_booking:list_event_bookings(UserId, EventId) of case logic_booking:list_event_bookings(UserId, EventId) of
{ok, Bookings} -> {ok, Bookings} ->
Response = [booking_to_json(B) || B <- Bookings], Response = [booking_to_json(B) || B <- Bookings],
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Формирует JSON-представление записи #booking{}.
-spec booking_to_json(#booking{}) -> map().
booking_to_json(Booking) -> booking_to_json(Booking) ->
#{ #{
id => Booking#booking.id, id => Booking#booking.id,
event_id => Booking#booking.event_id, event_id => Booking#booking.event_id,
user_id => Booking#booking.user_id, user_id => Booking#booking.user_id,
status => Booking#booking.status, status => Booking#booking.status,
confirmed_at => case Booking#booking.confirmed_at of notes => Booking#booking.notes,
undefined -> null; reminder_sent => Booking#booking.reminder_sent,
Dt -> datetime_to_iso8601(Dt) confirmed_at => case Booking#booking.confirmed_at of
end, undefined -> null;
created_at => datetime_to_iso8601(Booking#booking.created_at), Dt -> handler_utils:datetime_to_iso8601(Dt)
updated_at => datetime_to_iso8601(Booking#booking.updated_at) end,
}. created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,42 +1,159 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик конкретного календаря (клиентский API).
%%%
%%% GET получить информацию о календаре.
%%% PUT обновить календарь (владельцем).
%%% DELETE удалить календарь (владельцем).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_by_id). -module(handler_calendar_by_id).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_calendar(Req); <<"GET">> -> get_calendar(Req);
<<"PUT">> -> update_calendar(Req); <<"PUT">> -> update_calendar(Req);
<<"DELETE">> -> delete_calendar(Req); <<"DELETE">> -> delete_calendar(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/calendars/:id - получение календаря %%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BaseParams = [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
}
],
[
#{ % GET
path => <<"/v1/calendars/:id">>,
method => <<"GET">>,
description => <<"Get calendar by ID">>,
tags => [<<"Calendars">>],
parameters => BaseParams,
responses => #{
200 => #{
description => <<"Calendar details">>,
content => #{<<"application/json">> => #{schema => calendar_schema()}}
},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
},
#{ % PUT
path => <<"/v1/calendars/:id">>,
method => <<"PUT">>,
description => <<"Update calendar">>,
tags => [<<"Calendars">>],
parameters => BaseParams,
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => calendar_update_schema()}}
},
responses => #{
200 => #{description => <<"Calendar updated">>},
400 => #{description => <<"Invalid request">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
},
#{ % DELETE
path => <<"/v1/calendars/:id">>,
method => <<"DELETE">>,
description => <<"Delete calendar">>,
tags => [<<"Calendars">>],
parameters => BaseParams,
responses => #{
200 => #{description => <<"Calendar deleted">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
}
].
-spec calendar_schema() -> map().
calendar_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
owner_id => #{type => string},
title => #{type => string},
description => #{type => string},
short_name => #{type => string, nullable => true},
category => #{type => string, nullable => true},
color => #{type => string, nullable => true},
image_url => #{type => string, nullable => true},
settings => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}},
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>},
rating_avg => #{type => number, format => float},
rating_count => #{type => integer},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
-spec calendar_update_schema() -> map().
calendar_update_schema() ->
#{
type => object,
properties => #{
title => #{type => string},
description => #{type => string},
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>},
tags => #{type => array, items => #{type => string}}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @doc GET /v1/calendars/:id — получение календаря.
-spec get_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_calendar(Req) -> get_calendar(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1), CalendarId = cowboy_req:binding(id, Req1),
case logic_calendar:get_calendar(UserId, CalendarId) of case logic_calendar:get_calendar(UserId, CalendarId) of
{ok, Calendar} -> {ok, Calendar} ->
Response = calendar_to_json(Calendar), handler_utils:send_json(Req1, 200, handler_utils:calendar_to_json(Calendar));
send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Calendar not found">>); handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% PUT /v1/calendars/:id - обновление календаря %% @doc PUT /v1/calendars/:id обновление календаря.
-spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_calendar(Req) -> update_calendar(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1), CalendarId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
@@ -45,71 +162,39 @@ update_calendar(Req) ->
Updates = maps:to_list(UpdatesMap), Updates = maps:to_list(UpdatesMap),
case logic_calendar:update_calendar(UserId, CalendarId, Updates) of case logic_calendar:update_calendar(UserId, CalendarId, Updates) of
{ok, Calendar} -> {ok, Calendar} ->
Response = calendar_to_json(Calendar), handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Calendar));
send_json(Req2, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req2, 404, <<"Calendar not found">>); handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% DELETE /v1/calendars/:id - удаление календаря %% @doc DELETE /v1/calendars/:id удаление календаря.
-spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_calendar(Req) -> delete_calendar(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(id, Req1), CalendarId = cowboy_req:binding(id, Req1),
case logic_calendar:delete_calendar(UserId, CalendarId) of case logic_calendar:delete_calendar(UserId, CalendarId) of
{ok, _} -> {ok, _} ->
send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Calendar not found">>); handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции
calendar_to_json(Calendar) ->
#{
id => Calendar#calendar.id,
owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title,
description => Calendar#calendar.description,
tags => Calendar#calendar.tags,
type => Calendar#calendar.type,
confirmation => confirmation_to_json(Calendar#calendar.confirmation),
rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status,
created_at => Calendar#calendar.created_at,
updated_at => Calendar#calendar.updated_at
}.
confirmation_to_json(auto) -> <<"auto">>;
confirmation_to_json(manual) -> <<"manual">>;
confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,110 +1,144 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик календарного представления (HTML-календарь).
%%%
%%% GET возвращает HTML-страницу с календарём на указанный месяц.
%%% Требует параметр `month` в формате YYYY-MM.
%%% Доступно только владельцу календаря.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendar_view). -module(handler_calendar_view).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/calendars/:calendar_id/view">>,
method => <<"GET">>,
description => <<"Get calendar HTML view for a specific month">>,
tags => [<<"Calendars">>],
parameters => [
#{
name => <<"calendar_id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
},
#{
name => <<"month">>,
in => <<"query">>,
description => <<"Month in YYYY-MM format">>,
required => true,
schema => #{type => string, pattern => <<"^\\d{4}-\\d{2}$">>}
}
],
responses => #{
200 => #{
description => <<"HTML calendar page">>,
content => #{<<"text/html">> => #{schema => #{type => string}}}
},
400 => #{description => <<"Missing or invalid 'month' parameter">>},
401 => #{description => <<"Unauthorized">>},
403 => #{description => <<"Access denied">>}
}
}
].
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Основной обработчик запроса.
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) ->
CalendarId = cowboy_req:binding(calendar_id, Req), CalendarId = cowboy_req:binding(calendar_id, Req),
case verify_token(Req) of case handler_utils:auth_user(Req) of
{ok, UserId} -> {ok, UserId, Req1} ->
case is_owner(UserId, CalendarId) of case is_owner(UserId, CalendarId) of
true -> true -> process_view(Req1, CalendarId);
process_view(Req, CalendarId, Opts); false -> handler_utils:send_error(Req1, 403, <<"Access denied">>)
false ->
cowboy_req:reply(403,
#{<<"content-type">> => <<"application/json">>},
jsx:encode(#{error => <<"Access denied">>}),
Req),
{ok, Req, Opts}
end; end;
{error, _Reason} -> {error, _Code, _Msg, Req1} ->
cowboy_req:reply(401, handler_utils:send_error(Req1, 401, <<"Unauthorized">>)
#{<<"content-type">> => <<"application/json">>},
jsx:encode(#{error => <<"Unauthorized">>}),
Req),
{ok, Req, Opts}
end.
verify_token(Req) ->
case cowboy_req:header(<<"authorization">>, Req) of
undefined ->
{error, no_token};
<<"Bearer ", Token/binary>> ->
case eventhub_auth:verify_user_token(Token) of
{ok, UserId, _Role} -> {ok, UserId};
{error, _} -> {error, invalid_token}
end;
_ ->
{error, invalid_header}
end. end.
%% @private Проверяет, является ли пользователь владельцем календаря.
-spec is_owner(binary(), binary()) -> boolean().
is_owner(UserId, CalendarId) -> is_owner(UserId, CalendarId) ->
case mnesia:dirty_read({calendar, CalendarId}) of case mnesia:dirty_read({calendar, CalendarId}) of
[#calendar{owner_id = UserId}] -> true; [#calendar{owner_id = UserId}] -> true;
_ -> false _ -> false
end. end.
process_view(Req, CalendarId, Opts) -> %% @private Обрабатывает запрос на отображение календаря.
-spec process_view(cowboy_req:req(), binary()) -> {ok, cowboy_req:req(), any()}.
process_view(Req, CalendarId) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
MonthBin = case lists:keyfind(<<"month">>, 1, Qs) of case lists:keyfind(<<"month">>, 1, Qs) of
{<<"month">>, Value} -> Value; {<<"month">>, MonthBin} ->
false -> undefined
end,
case MonthBin of
undefined ->
cowboy_req:reply(400,
#{<<"content-type">> => <<"application/json">>},
jsx:encode(#{error => <<"Missing 'month' parameter">>}),
Req),
{ok, Req, Opts};
_ ->
case binary:split(MonthBin, <<"-">>) of case binary:split(MonthBin, <<"-">>) of
[YearStr, MonthStr] -> [YearStr, MonthStr] ->
Year = binary_to_integer(YearStr), Year = binary_to_integer(YearStr),
Month = binary_to_integer(MonthStr), Month = binary_to_integer(MonthStr),
Events = fetch_events(CalendarId, Year, Month), Events = fetch_events(CalendarId, Year, Month),
Html = calendar_html_renderer:render_month(Year, Month, Events), Html = calendar_html_renderer:render_month(Year, Month, Events),
Req2 = cowboy_req:reply(200, Headers = #{
#{<<"content-type">> => <<"text/html">>, <<"content-type">> => <<"text/html">>,
<<"cache-control">> => <<"public, max-age=86400">>}, <<"cache-control">> => <<"public, max-age=86400">>
Html, },
Req), cowboy_req:reply(200, Headers, Html, Req),
{ok, Req2, Opts}; {ok, Req, undefined};
_ -> _ ->
cowboy_req:reply(400, handler_utils:send_error(Req, 400, <<"Invalid 'month' format. Use YYYY-MM">>)
#{<<"content-type">> => <<"application/json">>}, end;
jsx:encode(#{error => <<"Invalid 'month' format. Use YYYY-MM">>}), false ->
Req), handler_utils:send_error(Req, 400, <<"Missing 'month' parameter">>)
{ok, Req, Opts}
end
end. end.
%% @private Извлекает события для указанного месяца календаря.
-spec fetch_events(binary(), integer(), integer()) -> list(#event{}).
fetch_events(CalendarId, Year, Month) -> fetch_events(CalendarId, Year, Month) ->
IsHot = is_hot(Year, Month), case is_hot(Year, Month) of
if IsHot -> true -> fetch_hot_events(CalendarId, Year, Month);
fetch_hot_events(CalendarId, Year, Month); false -> fetch_archive_events(CalendarId, Year, Month)
true ->
fetch_archive_events(CalendarId, Year, Month)
end. end.
%% @private Определяет, является ли месяц "горячим" (в пределах 30 дней от текущей даты).
-spec is_hot(integer(), integer()) -> boolean().
is_hot(Year, Month) -> is_hot(Year, Month) ->
Current = calendar:local_time(), Current = calendar:local_time(),
Target = {{Year, Month, 1}, {0,0,0}}, Target = {{Year, Month, 1}, {0, 0, 0}},
calendar:datetime_to_gregorian_seconds(Current) calendar:datetime_to_gregorian_seconds(Current) -
- calendar:datetime_to_gregorian_seconds(Target) < 30*86400. calendar:datetime_to_gregorian_seconds(Target) < 30 * 86400.
%% @private Извлекает "горячие" события из Mnesia.
-spec fetch_hot_events(binary(), integer(), integer()) -> list(#event{}).
fetch_hot_events(CalendarId, Year, Month) -> fetch_hot_events(CalendarId, Year, Month) ->
Start = {{Year, Month, 1}, {0,0,0}}, Start = {{Year, Month, 1}, {0, 0, 0}},
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23,59,59}}, End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
mnesia:dirty_select(event, mnesia:dirty_select(event, [
[{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'}, {#event{calendar_id = CalendarId, start_time = '$1', _ = '_'},
[{'>=','$1', {const, Start}}, {'=<','$1', {const, End}}], [{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
['$_']}]). ['$_']}
]).
%% @private Извлекает архивные события через RPC на архивный узел.
-spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}).
fetch_archive_events(CalendarId, Year, Month) -> fetch_archive_events(CalendarId, Year, Month) ->
DayStr = io_lib:format("~4.10.0B~2.10.0B", [Year, Month]), DayStr = io_lib:format("~4..0B~2..0B", [Year, Month]),
case archive_manager:get_archive_node(lists:flatten(DayStr)) of case archive_manager:get_archive_node(lists:flatten(DayStr)) of
{ok, Node} -> {ok, Node} -> rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month]);
rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month]); {error, _} -> []
{error, _} ->
[]
end. end.

View File

@@ -1,122 +1,194 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик маршрута `/v1/calendars`.
%%%
%%% POST создание нового календаря (требуется подписка для commercial).
%%% GET получение списка календарей пользователя.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_calendars). -module(handler_calendars).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
init(Req, Opts) -> -include("records.hrl").
handle(Req, Opts).
handle(Req, _Opts) -> %%% cowboy_handler callback
case cowboy_req:method(Req) of -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
<<"POST">> -> create_calendar(Req); init(Req0, _State) ->
<<"GET">> -> list_calendars(Req); case cowboy_req:method(Req0) of
_ -> send_error(Req, 405, <<"Method not allowed">>) <<"POST">> -> create_calendar(Req0);
<<"GET">> -> list_calendars(Req0);
_ -> handler_utils:send_error(Req0, 405, <<"Method not allowed">>)
end. end.
%% POST /v1/calendars - создание календаря %%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % POST
path => <<"/v1/calendars">>,
method => <<"POST">>,
description => <<"Create a new calendar">>,
tags => [<<"Calendars">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => calendar_create_schema()}}
},
responses => #{
201 => #{description => <<"Calendar created">>},
400 => #{description => <<"Missing required fields or invalid JSON">>},
402 => #{description => <<"Subscription required for commercial calendar">>},
403 => #{description => <<"User account is not active">>}
}
},
#{ % GET
path => <<"/v1/calendars">>,
method => <<"GET">>,
description => <<"List calendars of current user">>,
tags => [<<"Calendars">>],
responses => #{
200 => #{
description => <<"Array of calendars">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => calendar_schema()
}}}
}
}
}
].
-spec calendar_schema() -> map().
calendar_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
owner_id => #{type => string},
title => #{type => string},
description => #{type => string},
short_name => #{type => string, nullable => true},
category => #{type => string, nullable => true},
color => #{type => string, nullable => true},
image_url => #{type => string, nullable => true},
settings => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}},
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]},
confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>},
rating_avg => #{type => number, format => float},
rating_count => #{type => integer},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
-spec calendar_create_schema() -> map().
calendar_create_schema() ->
#{
type => object,
required => [<<"title">>],
properties => #{
title => #{type => string},
description => #{type => string},
confirmation => #{type => string, description => <<"auto, manual, or {timeout, N}">>},
tags => #{type => array, items => #{type => string}},
type => #{type => string, enum => [<<"personal">>, <<"commercial">>]}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @doc POST /v1/calendars — создание календаря.
-spec create_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_calendar(Req) -> create_calendar(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) -> Decoded when is_map(Decoded) ->
case Decoded of case Decoded of
#{<<"title">> := Title} -> #{<<"title">> := Title} ->
Description = maps:get(<<"description">>, Decoded, <<"">>), Description = maps:get(<<"description">>, Decoded, <<"">>),
Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)), Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)),
Tags = maps:get(<<"tags">>, Decoded, []), Tags = maps:get(<<"tags">>, Decoded, []),
Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)), Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)),
% Проверяем подписку для commercial календарей ДО создания
case Type of case Type of
commercial -> commercial ->
case logic_subscription:can_create_commercial_calendar(UserId) of case logic_subscription:can_create_commercial_calendar(UserId) of
true -> ok; true -> ok;
false -> false ->
send_error(Req2, 402, <<"Subscription required for commercial calendar">>), handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>),
throw(stop) throw(stop)
end; end;
personal -> ok personal -> ok
end, end,
case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of
{ok, Calendar} -> {ok, Calendar} ->
% Обновляем теги и тип
Updates = [{tags, Tags}, {type, Type}], Updates = [{tags, Tags}, {type, Type}],
core_calendar:update(Calendar#calendar.id, Updates), core_calendar:update(Calendar#calendar.id, Updates),
{ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id), {ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id),
Response = calendar_to_json(Updated), Response = calendar_to_json(Updated),
send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, user_inactive} -> {error, user_inactive} ->
send_error(Req2, 403, <<"User account is not active">>); handler_utils:send_error(Req2, 403, <<"User account is not active">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Missing required field: title">>) handler_utils:send_error(Req2, 400, <<"Missing required field: title">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
throw:stop -> ok; % Уже отправили ошибку throw:stop -> ok;
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
parse_confirmation(<<"auto">>) -> auto; %% @doc GET /v1/calendars — список календарей пользователя.
parse_confirmation(<<"manual">>) -> manual; -spec list_calendars(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
parse_confirmation(#{<<"timeout">> := N}) when is_integer(N), N > 0 -> {timeout, N};
parse_confirmation(_) -> manual.
parse_type(<<"personal">>) -> personal;
parse_type(<<"commercial">>) -> commercial;
parse_type(_) -> personal.
%% GET /v1/calendars - список календарей
list_calendars(Req) -> list_calendars(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
case logic_calendar:list_calendars(UserId) of case logic_calendar:list_calendars(UserId) of
{ok, Calendars} -> {ok, Calendars} ->
Response = [calendar_to_json(C) || C <- Calendars], Response = [calendar_to_json(C) || C <- Calendars],
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
%%% Внутренние функции
%%%===================================================================
-spec calendar_to_json(#calendar{}) -> map().
calendar_to_json(Calendar) -> calendar_to_json(Calendar) ->
#{ Base = handler_utils:calendar_to_json(Calendar),
id => Calendar#calendar.id, Base#{confirmation => confirmation_to_json(Calendar#calendar.confirmation)}.
owner_id => Calendar#calendar.owner_id,
title => Calendar#calendar.title,
description => Calendar#calendar.description,
tags => Calendar#calendar.tags,
type => Calendar#calendar.type,
confirmation => confirmation_to_json(Calendar#calendar.confirmation),
rating_avg => Calendar#calendar.rating_avg,
rating_count => Calendar#calendar.rating_count,
status => Calendar#calendar.status,
created_at => Calendar#calendar.created_at,
updated_at => Calendar#calendar.updated_at
}.
confirmation_to_json(auto) -> <<"auto">>; -spec confirmation_to_json(auto | manual | {timeout, integer()}) -> binary() | map().
confirmation_to_json(manual) -> <<"manual">>; confirmation_to_json(auto) -> <<"auto">>;
confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}. confirmation_to_json(manual) -> <<"manual">>;
confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}.
send_json(Req, Status, Data) -> -spec parse_confirmation(binary() | map()) -> auto | manual | {timeout, integer()}.
Body = jsx:encode(Data), parse_confirmation(<<"auto">>) -> auto;
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), parse_confirmation(<<"manual">>) -> manual;
{ok, Body, []}. parse_confirmation(#{<<"timeout">> := N}) when is_integer(N), N > 0 -> {timeout, N};
parse_confirmation(_) -> manual.
send_error(Req, Status, Message) -> -spec parse_type(binary()) -> personal | commercial.
Body = jsx:encode(#{error => Message}), parse_type(<<"personal">>) -> personal;
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), parse_type(<<"commercial">>) -> commercial;
{ok, Body, []}. parse_type(_) -> personal.

View File

@@ -1,42 +1,175 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик конкретного события (клиентский API).
%%%
%%% GET получить информацию о событии.
%%% PUT обновить событие.
%%% DELETE удалить событие.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_event_by_id). -module(handler_event_by_id).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BaseParams = [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Event ID">>,
required => true,
schema => #{type => string}
}
],
[
#{ % GET by id
path => <<"/v1/events/:id">>,
method => <<"GET">>,
description => <<"Get event by ID">>,
tags => [<<"Events">>],
parameters => BaseParams,
responses => #{
200 => #{
description => <<"Event details">>,
content => #{<<"application/json">> => #{schema => event_schema()}}
},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>}
}
},
#{ % PUT update
path => <<"/v1/events/:id">>,
method => <<"PUT">>,
description => <<"Update event">>,
tags => [<<"Events">>],
parameters => BaseParams,
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => event_update_schema()}}
},
responses => #{
200 => #{description => <<"Event updated">>},
400 => #{description => <<"Invalid request">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>}
}
},
#{ % DELETE
path => <<"/v1/events/:id">>,
method => <<"DELETE">>,
description => <<"Delete event">>,
tags => [<<"Events">>],
parameters => BaseParams,
responses => #{
200 => #{description => <<"Event deleted">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>}
}
}
].
event_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
calendar_id => #{type => string},
title => #{type => string},
description => #{type => string},
event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]},
start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer},
recurrence => #{type => object, nullable => true},
master_id => #{type => string, nullable => true},
is_instance => #{type => boolean},
specialist_id => #{type => string, nullable => true},
location => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}},
capacity => #{type => integer, nullable => true},
online_link => #{type => string, nullable => true},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
reason => #{type => string, nullable => true},
rating_avg => #{type => number, format => float},
rating_count => #{type => integer},
attachments => #{type => array, items => #{type => string}, nullable => true},
edit_history => #{type => array, items => #{type => object}, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
event_update_schema() ->
#{
type => object,
properties => #{
title => #{type => string},
description => #{type => string},
start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
specialist_id => #{type => string},
location => #{
type => object,
properties => #{
address => #{type => string},
lat => #{type => number, format => float},
lon => #{type => number, format => float}
}
},
tags => #{type => array, items => #{type => string}},
capacity => #{type => integer},
online_link => #{type => string}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_event(Req); <<"GET">> -> get_event(Req);
<<"PUT">> -> update_event(Req); <<"PUT">> -> update_event(Req);
<<"DELETE">> -> delete_event(Req); <<"DELETE">> -> delete_event(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/events/:id - получение события %% @doc GET /v1/events/:id получение события.
-spec get_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_event(Req) -> get_event(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
case logic_event:get_event(UserId, EventId) of case logic_event:get_event(UserId, EventId) of
{ok, Event} -> {ok, Event} ->
Response = event_to_json(Event), handler_utils:send_json(Req1, 200, handler_utils:event_to_json(Event));
send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% PUT /v1/events/:id - обновление события %% @doc PUT /v1/events/:id обновление события.
-spec update_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_event(Req) -> update_event(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
@@ -46,136 +179,83 @@ update_event(Req) ->
UpdatesWithTypes = convert_fields(Updates), UpdatesWithTypes = convert_fields(Updates),
case logic_event:update_event(UserId, EventId, UpdatesWithTypes) of case logic_event:update_event(UserId, EventId, UpdatesWithTypes) of
{ok, Event} -> {ok, Event} ->
Response = event_to_json(Event), handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event));
send_json(Req2, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req2, 404, <<"Event not found">>); handler_utils:send_error(Req2, 404, <<"Event not found">>);
{error, event_in_past} -> {error, event_in_past} ->
send_error(Req2, 400, <<"Event cannot be in the past">>); handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% DELETE /v1/events/:id - удаление события %% @doc DELETE /v1/events/:id удаление события.
-spec delete_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_event(Req) -> delete_event(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
case logic_event:delete_event(UserId, EventId) of case logic_event:delete_event(UserId, EventId) of
{ok, _} -> {ok, _} ->
send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
%% @private Преобразует поля из бинарных ключей в атомы и значения в правильные типы.
-spec convert_fields([{binary(), term()}]) -> [{atom(), term()}].
convert_fields(Updates) -> convert_fields(Updates) ->
lists:map(fun lists:map(fun convert_field/1, Updates).
({start_time, Value}) when is_binary(Value) ->
case parse_datetime(Value) of
{ok, DateTime} -> {start_time, DateTime};
_ -> {start_time, Value}
end;
({location, Value}) when is_map(Value) ->
case Value of
#{<<"lat">> := Lat, <<"lon">> := Lon} ->
Address = maps:get(<<"address">>, Value, <<"">>),
{location, #location{address = Address, lat = Lat, lon = Lon}};
_ -> {location, undefined}
end;
({tags, Value}) when is_list(Value) ->
{tags, Value};
(Other) -> Other
end, Updates).
parse_datetime(Str) -> -spec convert_field({binary(), term()}) -> {atom(), term()}.
try convert_field({<<"title">>, Val}) -> {title, Val};
[DateStr, TimeStr] = string:split(Str, "T"), convert_field({<<"description">>, Val}) -> {description, Val};
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), convert_field({<<"event_type">>, Val}) -> {event_type, Val};
convert_field({<<"start_time">>, Val}) ->
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), case handler_utils:parse_datetime(Val) of
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), {ok, Dt} -> {start_time, Dt};
_ -> {start_time, Val}
Year = binary_to_integer(YearStr), end;
Month = binary_to_integer(MonthStr), convert_field({<<"duration">>, Val}) -> {duration, Val};
Day = binary_to_integer(DayStr), convert_field({<<"recurrence">>, Val}) ->
Hour = binary_to_integer(HourStr), RuleJson = jsx:encode(Val),
Minute = binary_to_integer(MinuteStr), {recurrence_rule, RuleJson};
Second = binary_to_integer(SecondStr), convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
convert_field({<<"location">>, Val}) when is_map(Val) ->
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}} Loc = #location{
address = maps:get(<<"address">>, Val, undefined),
lat = maps:get(<<"lat">>, Val, undefined),
lon = maps:get(<<"lon">>, Val, undefined)
},
{location, Loc};
convert_field({<<"location">>, Val}) -> {location, Val};
convert_field({<<"tags">>, Val}) -> {tags, Val};
convert_field({<<"capacity">>, Val}) -> {capacity, Val};
convert_field({<<"online_link">>, Val}) -> {online_link, Val};
convert_field({<<"status">>, Val}) ->
try binary_to_existing_atom(Val, utf8) of
Atom -> {status, Atom}
catch catch
_:_ -> {error, invalid_format} error:badarg -> {status, Val}
end. end;
convert_field(Other) -> Other.
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 ->
Decoded = jsx:decode(Rule, [return_maps]),
case Decoded of
Map when is_map(Map) -> Map;
{ok, Map} -> Map
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,
rating_avg => Event#event.rating_avg,
rating_count => Event#event.rating_count,
created_at => datetime_to_iso8601(Event#event.created_at),
updated_at => datetime_to_iso8601(Event#event.updated_at)
}.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,143 +1,223 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик вхождений повторяющегося события (клиентский API).
%%%
%%% GET получить список вхождений события в заданном диапазоне.
%%% DELETE отменить конкретное вхождение.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_event_occurrences). -module(handler_event_occurrences).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % GET occurrences
path => <<"/v1/events/:id/occurrences">>,
method => <<"GET">>,
description => <<"Get event occurrences in a date range">>,
tags => [<<"Events">>],
parameters => [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Event ID">>,
required => true,
schema => #{type => string}
},
#{
name => <<"from">>,
in => <<"query">>,
description => <<"Start datetime (ISO8601)">>,
required => true,
schema => #{type => string, format => <<"date-time">>}
},
#{
name => <<"to">>,
in => <<"query">>,
description => <<"End datetime (ISO8601)">>,
required => true,
schema => #{type => string, format => <<"date-time">>}
}
],
responses => #{
200 => #{
description => <<"Array of occurrences">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => occurrence_schema()
}}}
},
400 => #{description => <<"Missing or invalid parameters">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>}
}
},
#{ % DELETE occurrence
path => <<"/v1/events/:id/occurrences/:start_time">>,
method => <<"DELETE">>,
description => <<"Cancel a specific occurrence">>,
tags => [<<"Events">>],
parameters => [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Event ID">>,
required => true,
schema => #{type => string}
},
#{
name => <<"start_time">>,
in => <<"path">>,
description => <<"Start time of the occurrence (ISO8601)">>,
required => true,
schema => #{type => string, format => <<"date-time">>}
}
],
responses => #{
200 => #{description => <<"Occurrence cancelled">>},
400 => #{description => <<"Missing or invalid parameters">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Event not found">>}
}
}
].
occurrence_schema() ->
#{
type => object,
properties => #{
start_time => #{type => string, format => <<"date-time">>},
is_virtual => #{type => boolean},
id => #{type => string, description => <<"Event ID (only for materialized occurrences)">>},
duration => #{type => integer, description => <<"Duration in minutes (only for materialized occurrences)">>},
specialist_id => #{type => string, description => <<"Specialist ID (only for materialized occurrences)">>},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>], description => <<"Status (only for materialized occurrences)">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_occurrences(Req); <<"GET">> -> get_occurrences(Req);
<<"DELETE">> -> cancel_occurrence(Req); <<"DELETE">> -> cancel_occurrence(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/events/:id/occurrences - получение вхождений повторяющегося события %% @doc GET /v1/events/:id/occurrences получение вхождений повторяющегося события.
-spec get_occurrences(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_occurrences(Req) -> get_occurrences(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
% Параметры диапазона
From = proplists:get_value(<<"from">>, Qs, undefined), From = proplists:get_value(<<"from">>, Qs, undefined),
To = proplists:get_value(<<"to">>, Qs, undefined), To = proplists:get_value(<<"to">>, Qs, undefined),
case {From, To} of case {From, To} of
{undefined, _} -> {undefined, _} ->
send_error(Req1, 400, <<"Missing 'from' parameter">>); handler_utils:send_error(Req1, 400, <<"Missing 'from' parameter">>);
{_, undefined} -> {_, undefined} ->
send_error(Req1, 400, <<"Missing 'to' parameter">>); handler_utils:send_error(Req1, 400, <<"Missing 'to' parameter">>);
{FromStr, ToStr} -> {FromStr, ToStr} ->
case {parse_datetime(FromStr), parse_datetime(ToStr)} of case {handler_utils:parse_datetime(FromStr), handler_utils:parse_datetime(ToStr)} of
{{ok, FromDt}, {ok, ToDt}} -> {{ok, FromDt}, {ok, ToDt}} ->
case logic_event:get_occurrences(UserId, EventId, ToDt) of case logic_event:get_occurrences(UserId, EventId, ToDt) of
{ok, Occurrences} -> {ok, Occurrences} ->
% Фильтруем по from
Filtered = filter_from(Occurrences, FromDt), Filtered = filter_from(Occurrences, FromDt),
Response = occurrences_to_json(Filtered), Response = occurrences_to_json(Filtered),
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, not_recurring} -> {error, not_recurring} ->
send_error(Req1, 400, <<"Event is not recurring">>); handler_utils:send_error(Req1, 400, <<"Event is not recurring">>);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req1, 400, <<"Invalid date format. Use ISO 8601">>) handler_utils:send_error(Req1, 400, <<"Invalid date format. Use ISO 8601">>)
end end
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% DELETE /v1/events/:id/occurrences/:start_time - отмена вхождения %% @doc DELETE /v1/events/:id/occurrences/:start_time отмена вхождения.
-spec cancel_occurrence(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
cancel_occurrence(Req) -> cancel_occurrence(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
EventId = cowboy_req:binding(id, Req1), EventId = cowboy_req:binding(id, Req1),
StartTimeStr = cowboy_req:binding(start_time, Req1), StartTimeStr = cowboy_req:binding(start_time, Req1),
case handler_utils:parse_datetime(StartTimeStr) of
case parse_datetime(StartTimeStr) of
{ok, StartTime} -> {ok, StartTime} ->
case logic_event:cancel_occurrence(UserId, EventId, StartTime) of case logic_event:cancel_occurrence(UserId, EventId, StartTime) of
{ok, cancelled} -> {ok, cancelled} ->
send_json(Req1, 200, #{status => <<"cancelled">>}); handler_utils:send_json(Req1, 200, #{status => <<"cancelled">>});
{error, not_recurring} -> {error, not_recurring} ->
send_error(Req1, 400, <<"Event is not recurring">>); handler_utils:send_error(Req1, 400, <<"Event is not recurring">>);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Event not found">>); handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, _} -> {error, _} ->
send_error(Req1, 400, <<"Invalid start_time format">>) handler_utils:send_error(Req1, 400, <<"Invalid start_time format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
parse_datetime(Str) -> %%% Вспомогательные функции
try %%%===================================================================
[DateStr, TimeStr] = string:split(Str, "T"),
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
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.
%% @private Фильтрует вхождения, оставляя только те, что не раньше From.
-spec filter_from(list(), calendar:datetime()) -> list().
filter_from(Occurrences, From) -> filter_from(Occurrences, From) ->
lists:filter(fun lists:filter(
({virtual, Occ}) -> Occ >= From; fun({virtual, Occ}) ->
({materialized, Event}) -> Event#event.start_time >= From Occ >= From;
end, Occurrences). ({materialized, Event}) ->
Event#event.start_time >= From
end, Occurrences).
%% @private Преобразует список вхождений в JSON-представление.
-spec occurrences_to_json(list()) -> list().
occurrences_to_json(Occurrences) -> occurrences_to_json(Occurrences) ->
lists:map(fun occurrence_to_json/1, Occurrences). lists:map(fun occurrence_to_json/1, Occurrences).
%% @private Преобразует одно вхождение в JSON-карту.
-spec occurrence_to_json({virtual, calendar:datetime()} | {materialized, #event{}}) -> map().
occurrence_to_json({virtual, Occ}) -> occurrence_to_json({virtual, Occ}) ->
#{ #{
start_time => datetime_to_iso8601(Occ), start_time => handler_utils:datetime_to_iso8601(Occ),
is_virtual => true is_virtual => true
}; };
occurrence_to_json({materialized, Event}) -> occurrence_to_json({materialized, Event}) ->
#{ #{
id => Event#event.id, id => Event#event.id,
start_time => datetime_to_iso8601(Event#event.start_time), start_time => handler_utils:datetime_to_iso8601(Event#event.start_time),
duration => Event#event.duration, duration => Event#event.duration,
specialist_id => Event#event.specialist_id, specialist_id => Event#event.specialist_id,
is_virtual => false, is_virtual => false,
status => Event#event.status status => Event#event.status
}. }.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,148 +1,270 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик маршрута `/v1/calendars/:calendar_id/events`.
%%%
%%% POST создание нового события (одиночного или повторяющегося).
%%% GET получение списка событий календаря с возможностью фильтрации
%%% по диапазону дат и разворачиванием повторяющихся событий.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_events). -module(handler_events).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % POST create event
path => <<"/v1/calendars/:calendar_id/events">>,
method => <<"POST">>,
description => <<"Create a new event (single or recurring)">>,
tags => [<<"Events">>],
parameters => [
#{
name => <<"calendar_id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
}
],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => event_create_schema()}}
},
responses => #{
201 => #{description => <<"Event created">>},
400 => #{description => <<"Missing required fields or invalid JSON">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
},
#{ % GET list events
path => <<"/v1/calendars/:calendar_id/events">>,
method => <<"GET">>,
description => <<"List events of a calendar with optional date range">>,
tags => [<<"Events">>],
parameters => [
#{
name => <<"calendar_id">>,
in => <<"path">>,
description => <<"Calendar ID">>,
required => true,
schema => #{type => string}
},
#{
name => <<"from">>,
in => <<"query">>,
description => <<"Start datetime (ISO8601)">>,
required => false,
schema => #{type => string, format => <<"date-time">>}
},
#{
name => <<"to">>,
in => <<"query">>,
description => <<"End datetime (ISO8601)">>,
required => false,
schema => #{type => string, format => <<"date-time">>}
}
],
responses => #{
200 => #{
description => <<"Array of events">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => event_schema()
}}}
},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Calendar not found">>}
}
}
].
event_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
calendar_id => #{type => string},
title => #{type => string},
description => #{type => string},
event_type => #{type => string, enum => [<<"single">>, <<"recurring">>]},
start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer},
recurrence => #{type => object, nullable => true},
master_id => #{type => string, nullable => true},
is_instance => #{type => boolean},
specialist_id => #{type => string, nullable => true},
location => #{type => object, nullable => true},
tags => #{type => array, items => #{type => string}},
capacity => #{type => integer, nullable => true},
online_link => #{type => string, nullable => true},
status => #{type => string, enum => [<<"active">>, <<"cancelled">>, <<"completed">>]},
reason => #{type => string, nullable => true},
rating_avg => #{type => number, format => float},
rating_count => #{type => integer},
attachments => #{type => array, items => #{type => string}, nullable => true},
edit_history => #{type => array, items => #{type => object}, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
event_create_schema() ->
#{
type => object,
required => [<<"title">>, <<"start_time">>, <<"duration">>],
properties => #{
title => #{type => string},
start_time => #{type => string, format => <<"date-time">>},
duration => #{type => integer, description => <<"Duration in minutes">>},
description => #{type => string},
tags => #{type => array, items => #{type => string}},
capacity => #{type => integer},
online_link => #{type => string},
location => #{
type => object,
properties => #{
address => #{type => string},
lat => #{type => number, format => float},
lon => #{type => number, format => float}
}
},
recurrence => #{type => object, description => <<"Recurrence rule (RFC 5545)">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"POST">> -> create_event(Req); <<"POST">> -> create_event(Req);
<<"GET">> -> list_events(Req); <<"GET">> -> list_events(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% POST /v1/calendars/:calendar_id/events - создание события %% @doc POST /v1/calendars/:calendar_id/events создание события.
-spec create_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_event(Req) -> create_event(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(calendar_id, Req1), CalendarId = cowboy_req:binding(calendar_id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) -> Decoded when is_map(Decoded) ->
case Decoded of case Decoded of
#{<<"title">> := Title, #{<<"title">> := Title, <<"start_time">> := StartTimeStr, <<"duration">> := Duration} ->
<<"start_time">> := StartTimeStr, case handler_utils:parse_datetime(StartTimeStr) of
<<"duration">> := Duration} ->
case parse_datetime(StartTimeStr) of
{ok, StartTime} -> {ok, StartTime} ->
% Парсим location если есть
Location = parse_location(maps:get(<<"location">>, Decoded, undefined)), Location = parse_location(maps:get(<<"location">>, Decoded, undefined)),
% Проверяем, есть ли правило повторения
case maps:get(<<"recurrence">>, Decoded, undefined) of case maps:get(<<"recurrence">>, Decoded, undefined) of
undefined -> undefined ->
% Одиночное событие
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of
{ok, Event} -> {ok, Event} ->
% Обновляем location и capacity если нужно
update_event_fields(Event#event.id, Location, Decoded), update_event_fields(Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = event_to_json(UpdatedEvent), Response = handler_utils:event_to_json(UpdatedEvent),
send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req2, 404, <<"Calendar not found">>); handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} -> {error, event_in_past} ->
send_error(Req2, 400, <<"Event cannot be in the past">>); handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
RRule -> RRule ->
% Повторяющееся событие
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of
{ok, Event} -> {ok, Event} ->
update_event_fields(Event#event.id, Location, Decoded), update_event_fields(Event#event.id, Location, Decoded),
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
Response = event_to_json(UpdatedEvent), Response = handler_utils:event_to_json(UpdatedEvent),
send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, invalid_rrule} -> {error, invalid_rrule} ->
send_error(Req2, 400, <<"Invalid recurrence rule">>); handler_utils:send_error(Req2, 400, <<"Invalid recurrence rule">>);
{error, access_denied} -> {error, access_denied} ->
send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req2, 404, <<"Calendar not found">>); handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
{error, event_in_past} -> {error, event_in_past} ->
send_error(Req2, 400, <<"Event cannot be in the past">>); handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end end
end; end;
{error, _} -> {error, _} ->
send_error(Req2, 400, <<"Invalid start_time format. Use ISO 8601">>) handler_utils:send_error(Req2, 400, <<"Invalid start_time format. Use ISO 8601">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Missing required fields: title, start_time, duration">>) handler_utils:send_error(Req2, 400, <<"Missing required fields: title, start_time, duration">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% GET /v1/calendars/:calendar_id/events - список событий %% @doc GET /v1/calendars/:calendar_id/events список событий.
-spec list_events(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_events(Req) -> list_events(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
CalendarId = cowboy_req:binding(calendar_id, Req1), CalendarId = cowboy_req:binding(calendar_id, Req1),
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
From = proplists:get_value(<<"from">>, Qs, undefined), From = proplists:get_value(<<"from">>, Qs, undefined),
To = proplists:get_value(<<"to">>, Qs, undefined), To = proplists:get_value(<<"to">>, Qs, undefined),
case logic_event:list_events(UserId, CalendarId) of case logic_event:list_events(UserId, CalendarId) of
{ok, Events} -> {ok, Events} ->
Response = case {From, To} of Response = case {From, To} of
{undefined, undefined} -> {undefined, undefined} ->
[event_to_json(E) || E <- Events]; [handler_utils:event_to_json(E) || E <- Events];
{FromStr, ToStr} -> {FromStr, ToStr} ->
FromDt = parse_datetime_binary(FromStr), FromDt = parse_datetime_binary(FromStr),
ToDt = parse_datetime_binary(ToStr), ToDt = parse_datetime_binary(ToStr),
expand_recurring_events(UserId, Events, FromDt, ToDt) expand_recurring_events(UserId, Events, FromDt, ToDt)
end, end,
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Calendar not found">>); handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
update_event_fields(EventId, Location, Decoded) -> update_event_fields(EventId, Location, Decoded) ->
Updates = [], Updates = [],
Updates1 = case Location of Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end,
undefined -> Updates; Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of undefined -> Updates1; Cap -> [{capacity, Cap} | Updates1] end,
_ -> [{location, Location} | Updates] Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of undefined -> Updates2; Tags -> [{tags, Tags} | Updates2] end,
end, Updates4 = case maps:get(<<"description">>, Decoded, undefined) of undefined -> Updates3; Desc -> [{description, Desc} | Updates3] end,
Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of Updates5 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates4; Link -> [{online_link, Link} | Updates4] end,
undefined -> Updates1; if Updates5 /= [] -> core_event:update(EventId, Updates5); true -> ok end.
Cap -> [{capacity, Cap} | Updates1]
end,
Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of
undefined -> Updates2;
Tags -> [{tags, Tags} | Updates2]
end,
Updates4 = case maps:get(<<"description">>, Decoded, undefined) of
undefined -> Updates3;
Desc -> [{description, Desc} | Updates3]
end,
Updates5 = case maps:get(<<"online_link">>, Decoded, undefined) of
undefined -> Updates4;
Link -> [{online_link, Link} | Updates4]
end,
if Updates5 /= [] -> core_event:update(EventId, Updates5);
true -> ok
end.
parse_location(undefined) -> undefined; parse_location(undefined) -> undefined;
parse_location(LocationMap) when is_map(LocationMap) -> parse_location(LocationMap) when is_map(LocationMap) ->
@@ -159,116 +281,40 @@ expand_recurring_events(UserId, Events, From, To) ->
case Event#event.event_type of case Event#event.event_type of
single -> single ->
case is_in_range(Event#event.start_time, From, To) of case is_in_range(Event#event.start_time, From, To) of
true -> [event_to_json(Event)]; true -> [handler_utils:event_to_json(Event)];
false -> [] false -> []
end; end;
recurring -> recurring ->
case logic_event:get_occurrences(UserId, Event#event.id, To) of case logic_event:get_occurrences(UserId, Event#event.id, To) of
{ok, Occurrences} -> {ok, Occurrences} ->
lists:filtermap(fun lists:filtermap(
({virtual, Occ}) -> fun({virtual, Occ}) ->
case is_in_range(Occ, From, To) of case is_in_range(Occ, From, To) of
true -> {true, occurrence_to_json(Event, Occ)}; true -> {true, occurrence_to_json(Event, Occ)};
false -> false false -> false
end; end;
({materialized, Instance}) -> ({materialized, Instance}) ->
case is_in_range(Instance#event.start_time, From, To) of case is_in_range(Instance#event.start_time, From, To) of
true -> {true, event_to_json(Instance)}; true -> {true, handler_utils:event_to_json(Instance)};
false -> false false -> false
end end
end, Occurrences); end, Occurrences);
_ -> _ -> []
[]
end end
end end
end, Events). end, Events).
is_in_range(Time, From, To) -> is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
Time >= From andalso Time =< To.
parse_datetime_binary(Str) -> parse_datetime_binary(Str) ->
{ok, Dt} = parse_datetime(Str), {ok, Dt} = handler_utils:parse_datetime(Str),
Dt. Dt.
parse_datetime(Str) ->
try
[DateStr, TimeStr] = string:split(Str, "T"),
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all),
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all),
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.
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 ->
Decoded = jsx:decode(Rule, [return_maps]),
case Decoded of
Map when is_map(Map) -> Map;
{ok, Map} -> Map
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,
rating_avg => Event#event.rating_avg,
rating_count => Event#event.rating_count,
created_at => datetime_to_iso8601(Event#event.created_at),
updated_at => datetime_to_iso8601(Event#event.updated_at)
}.
occurrence_to_json(Master, Occurrence) -> occurrence_to_json(Master, Occurrence) ->
#{ #{
master_id => Master#event.id, master_id => Master#event.id,
start_time => datetime_to_iso8601(Occurrence), start_time => handler_utils:datetime_to_iso8601(Occurrence),
duration => Master#event.duration, duration => Master#event.duration,
title => Master#event.title, title => Master#event.title,
is_virtual => true is_virtual => true
}. }.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,18 +1,52 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик проверки здоровья клиентского API.
%%% GET возвращает статус сервера.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_health). -module(handler_health).
-behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/health">>,
method => <<"GET">>,
description => <<"API health check">>,
tags => [<<"Health">>],
responses => #{
200 => #{
description => <<"API is healthy">>,
content => #{<<"application/json">> => #{schema => #{
type => object,
properties => #{
status => #{type => string}
}
}}}
}
}
}
].
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> <<"GET">> ->
Body = jsx:encode(#{status => <<"ok">>}), handler_utils:send_json(Req, 200, #{status => <<"ok">>});
Req1 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Req1, []};
_ -> _ ->
Body = jsx:encode(#{error => <<"Method not allowed">>}), handler_utils:send_error(Req, 405, <<"Method not allowed">>)
Req1 = cowboy_req:reply(405, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Req1, []}
end. end.

View File

@@ -1,69 +1,104 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик входа пользователя (клиентский API).
%%% POST аутентифицирует пользователя по email и паролю,
%%% возвращает JWT токен и refresh токен.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_login). -module(handler_login).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
init(Req0, State) -> %%% cowboy_handler callback
handle(Req0, State). -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, Opts) ->
handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/login">>,
method => <<"POST">>,
description => <<"User login">>,
tags => [<<"Auth">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"email">>, <<"password">>],
properties => #{
email => #{type => string, format => <<"email">>},
password => #{type => string, format => <<"password">>}
}
}}}
},
responses => #{
200 => #{description => <<"Login successful, returns token and user info">>},
400 => #{description => <<"Missing email or password, or invalid JSON">>},
401 => #{description => <<"Invalid credentials">>},
403 => #{description => <<"Account frozen or deleted">>}
}
}
].
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"POST">> -> <<"POST">> -> login(Req);
case cowboy_req:has_body(Req) of _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
true ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
case Body of
<<>> ->
send_error(Req1, 400, <<"Empty request body">>);
_ ->
try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} ->
case eventhub_auth:authenticate_user_request(Req1, Email, Password) of
{ok, Token, User} ->
{RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(maps:get(id, User)),
core_session:create(maps:get(id, User), RefreshToken),
core_user:update_last_login(maps:get(id, User)),
Response = #{
user => #{
id => maps:get(id, User),
email => maps:get(email, User),
role => maps:get(role, User)
},
token => Token,
refresh_token => RefreshToken
},
send_json(Req1, 200, Response);
{error, frozen} ->
send_error(Req1, 403, <<"Account frozen">>);
{error, deleted} ->
send_error(Req1, 403, <<"Account deleted">>);
{error, _Reason} ->
send_error(Req1, 401, <<"Invalid credentials">>)
end;
_ ->
send_error(Req1, 400, <<"Missing email or password">>)
catch
_:_ -> send_error(Req1, 400, <<"Invalid JSON">>)
end
end;
false ->
send_error(Req, 400, <<"Missing request body">>)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end. end.
send_json(Req, Status, Data) -> %% @doc POST /v1/login — аутентификация пользователя.
Body = jsx:encode(Data), -spec login(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
cowboy_req:reply(Status, #{ login(Req) ->
<<"content-type">> => <<"application/json">> case cowboy_req:has_body(Req) of
}, Body, Req), true ->
{ok, Body, []}. {ok, Body, Req1} = cowboy_req:read_body(Req),
case Body of
send_error(Req, Status, Message) -> <<>> ->
Body = jsx:encode(#{error => Message}), handler_utils:send_error(Req1, 400, <<"Empty request body">>);
cowboy_req:reply(Status, #{ _ ->
<<"content-type">> => <<"application/json">> try jsx:decode(Body, [return_maps]) of
}, Body, Req), #{<<"email">> := Email, <<"password">> := Password} ->
{ok, Body, []}. case eventhub_auth:authenticate_user_request(Req1, Email, Password) of
{ok, Token, User} ->
UserId = maps:get(id, User),
{RefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId),
core_session:create(UserId, RefreshToken),
core_user:update_last_login(UserId),
Response = #{
<<"token">> => Token,
<<"user">> => #{
<<"id">> => UserId,
<<"email">> => maps:get(email, User),
<<"role">> => maps:get(role, User)
},
<<"refresh_token">> => RefreshToken
},
handler_utils:send_json(Req1, 200, Response);
{error, frozen} ->
handler_utils:send_error(Req1, 403, <<"Account frozen">>);
{error, deleted} ->
handler_utils:send_error(Req1, 403, <<"Account deleted">>);
{error, _Reason} ->
handler_utils:send_error(Req1, 401, <<"Invalid credentials">>)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Missing email or password">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end
end;
false ->
handler_utils:send_error(Req, 400, <<"Missing request body">>)
end.

View File

@@ -1,33 +1,79 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик обновления токена (клиентский API).
%%% POST принимает refresh_token, возвращает новую пару access + refresh токенов.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_refresh). -module(handler_refresh).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]).
init(Req0, _Opts) -> -export([init/2]).
case cowboy_req:method(Req0) of -export([trails/0]).
<<"POST">> ->
{ok, Body, Req1} = cowboy_req:read_body(Req0), -include("records.hrl").
try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} -> %%% cowboy_handler callback
case find_and_refresh(RefreshToken) of -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
{ok, NewTokenPair} -> init(Req, _Opts) ->
Resp = jsx:encode(NewTokenPair), case cowboy_req:method(Req) of
cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Resp, Req1); <<"POST">> -> refresh(Req);
{error, Reason} -> _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
send_error(Req1, 401, Reason)
end;
_ ->
send_error(Req1, 400, <<"Missing refresh_token field">>)
catch
_:_ -> send_error(Req1, 400, <<"Invalid JSON">>)
end;
_ ->
send_error(Req0, 405, <<"Method not allowed">>)
end. end.
%% -------------------------------------------------------- %%% Swagger metadata
%% Общая логика: проверяет обе таблицы сессий -spec trails() -> [map()].
%% -------------------------------------------------------- trails() ->
[
#{
path => <<"/v1/refresh">>,
method => <<"POST">>,
description => <<"Refresh access token using refresh token">>,
tags => [<<"Auth">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"refresh_token">>],
properties => #{
refresh_token => #{type => string}
}
}}}
},
responses => #{
200 => #{description => <<"New token pair (access + refresh)">>},
400 => #{description => <<"Missing refresh_token field or invalid JSON">>},
401 => #{description => <<"Refresh token expired or not found">>}
}
}
].
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @doc POST /v1/refresh — обновление токена.
-spec refresh(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
refresh(Req) ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
try jsx:decode(Body, [return_maps]) of
#{<<"refresh_token">> := RefreshToken} ->
case find_and_refresh(RefreshToken) of
{ok, NewTokenPair} ->
handler_utils:send_json(Req1, 200, NewTokenPair);
{error, Reason} ->
handler_utils:send_error(Req1, 401, Reason)
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Missing refresh_token field">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end.
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Проверяет refresh-токен в таблицах пользовательских и админских сессий.
-spec find_and_refresh(binary()) -> {ok, map()} | {error, binary()}.
find_and_refresh(RefreshToken) -> find_and_refresh(RefreshToken) ->
case core_session:validate(RefreshToken) of case core_session:validate(RefreshToken) of
{ok, UserId, User} -> {ok, UserId, User} ->
@@ -45,31 +91,23 @@ find_and_refresh(RefreshToken) ->
{error, <<"Refresh token expired">>} {error, <<"Refresh token expired">>}
end. end.
%% @private Обновляет токен для обычного пользователя.
-spec user_refresh(binary(), #user{}, binary()) -> {ok, map()}.
user_refresh(UserId, User, OldToken) -> user_refresh(UserId, User, OldToken) ->
% Удаляем старый refresh-токен
core_session:delete(OldToken), core_session:delete(OldToken),
% Генерируем новый access-токен и refresh-токен
Role = atom_to_binary(User#user.role, utf8), Role = atom_to_binary(User#user.role, utf8),
NewToken = eventhub_auth:generate_user_token(UserId, Role), NewToken = eventhub_auth:generate_user_token(UserId, Role),
{NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId), {NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId),
% Сохраняем новый refresh-токен в таблице session
core_session:create(UserId, NewRefreshToken), core_session:create(UserId, NewRefreshToken),
{ok, #{token => NewToken, refresh_token => NewRefreshToken}}. {ok, #{token => NewToken, refresh_token => NewRefreshToken}}.
%% @private Обновляет токен для администратора.
-spec admin_refresh(binary(), binary()) -> {ok, map()}.
admin_refresh(AdminId, OldToken) -> admin_refresh(AdminId, OldToken) ->
% Удаляем старый refresh-токен
core_admin_session:delete(OldToken), core_admin_session:delete(OldToken),
% Получаем роль админа
{ok, Admin} = core_admin:get_by_id(AdminId), {ok, Admin} = core_admin:get_by_id(AdminId),
Role = atom_to_binary(Admin#admin.role, utf8), Role = atom_to_binary(Admin#admin.role, utf8),
% Генерируем новый access-токен и refresh-токен
NewToken = eventhub_auth:generate_admin_token(AdminId, Role), NewToken = eventhub_auth:generate_admin_token(AdminId, Role),
{NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(AdminId), {NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(AdminId),
% Сохраняем новый refresh-токен в таблице admin_session
core_admin_session:create(AdminId, NewRefreshToken), core_admin_session:create(AdminId, NewRefreshToken),
{ok, #{token => NewToken, refresh_token => NewRefreshToken}}. {ok, #{token => NewToken, refresh_token => NewRefreshToken}}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,65 +1,104 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик регистрации пользователя (клиентский API).
%%% POST создаёт нового пользователя, возвращает JWT токен.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_register). -module(handler_register).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/register">>,
method => <<"POST">>,
description => <<"Register a new user">>,
tags => [<<"Auth">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"email">>, <<"password">>],
properties => #{
email => #{type => string, format => <<"email">>},
password => #{type => string, format => <<"password">>}
}
}}}
},
responses => #{
201 => #{description => <<"User registered, returns token and user info">>},
400 => #{description => <<"Missing email or password, or invalid JSON">>},
409 => #{description => <<"Email already exists">>}
}
}
].
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"POST">> -> <<"POST">> -> register(Req);
case cowboy_req:has_body(Req) of _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
true ->
{ok, Body, Req1} = cowboy_req:read_body(Req),
case Body of
<<>> ->
send_error(Req1, 400, <<"Empty request body">>);
_ ->
try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} ->
case core_user:email_exists(Email) of
true ->
send_error(Req1, 409, <<"Email already exists">>);
false ->
case core_user:create(Email, Password) of
{ok, User} ->
Token = logic_auth:generate_jwt(User#user.id, atom_to_binary(User#user.role, utf8)),
Response = #{
user => #{
id => User#user.id,
email => User#user.email,
role => User#user.role
},
token => Token
},
send_json(Req1, 201, Response);
{error, email_exists} ->
send_error(Req1, 409, <<"Email already exists">>);
{error, _} ->
send_error(Req1, 500, <<"Internal server error">>)
end
end;
_ ->
send_error(Req1, 400, <<"Missing email or password">>)
catch
_:_ ->
send_error(Req1, 400, <<"Invalid JSON">>)
end
end;
false ->
send_error(Req, 400, <<"Missing request body">>)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end. end.
send_json(Req, Status, Data) -> %% @doc POST /v1/register — регистрация нового пользователя.
Body = jsx:encode(Data), -spec register(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), register(Req) ->
{ok, Body, []}. case cowboy_req:has_body(Req) of
true ->
send_error(Req, Status, Message) -> {ok, Body, Req1} = cowboy_req:read_body(Req),
Body = jsx:encode(#{error => Message}), case Body of
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), <<>> ->
{ok, Body, []}. handler_utils:send_error(Req1, 400, <<"Empty request body">>);
_ ->
try jsx:decode(Body, [return_maps]) of
#{<<"email">> := Email, <<"password">> := Password} ->
case core_user:email_exists(Email) of
true ->
handler_utils:send_error(Req1, 409, <<"Email already exists">>);
false ->
case core_user:create(Email, Password) of
{ok, User} ->
Token = logic_auth:generate_jwt(
User#user.id,
atom_to_binary(User#user.role, utf8)
),
Response = #{
user => #{
id => User#user.id,
email => User#user.email,
role => User#user.role
},
token => Token
},
handler_utils:send_json(Req1, 201, Response);
{error, email_exists} ->
handler_utils:send_error(Req1, 409, <<"Email already exists">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end
end;
_ ->
handler_utils:send_error(Req1, 400, <<"Missing email or password">>)
catch
_:_ -> handler_utils:send_error(Req1, 400, <<"Invalid JSON">>)
end
end;
false ->
handler_utils:send_error(Req, 400, <<"Missing request body">>)
end.

View File

@@ -1,105 +1,169 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик жалоб (клиентский API).
%%%
%%% POST создание новой жалобы (пользователем).
%%% GET получение списка жалоб (для администратора/модератора).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_reports). -module(handler_reports).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % POST create report
path => <<"/v1/reports">>,
method => <<"POST">>,
description => <<"Create a new report (complaint)">>,
tags => [<<"Reports">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"target_type">>, <<"target_id">>, <<"reason">>],
properties => #{
target_type => #{type => string, enum => [<<"event">>, <<"calendar">>]},
target_id => #{type => string},
reason => #{type => string}
}
}}}
},
responses => #{
201 => #{description => <<"Report created">>},
400 => #{description => <<"Missing required fields or invalid JSON">>},
404 => #{description => <<"Target not found">>}
}
},
#{ % GET list reports (admin/mod)
path => <<"/v1/reports">>,
method => <<"GET">>,
description => <<"List reports (admin/moderator only)">>,
tags => [<<"Reports">>],
parameters => [
#{name => <<"target_type">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by target type">>},
#{name => <<"target_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by target ID">>}
],
responses => #{
200 => #{
description => <<"Array of reports">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => report_schema()
}}}
},
403 => #{description => <<"Access denied (admin/mod only)">>}
}
}
].
report_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
reporter_id => #{type => string},
target_type => #{type => string, enum => [<<"event">>, <<"calendar">>, <<"review">>]},
target_id => #{type => string},
reason => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"reviewed">>, <<"dismissed">>]},
created_at => #{type => string, format => <<"date-time">>},
resolved_at => #{type => string, format => <<"date-time">>, nullable => true},
resolved_by => #{type => string, nullable => true}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"POST">> -> create_report(Req); <<"POST">> -> create_report(Req);
<<"GET">> -> list_reports(Req); <<"GET">> -> list_reports(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% @doc POST /v1/reports — создание жалобы.
-spec create_report(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_report(Req) -> create_report(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
Decoded = jsx:decode(Body, [return_maps]), Decoded = jsx:decode(Body, [return_maps]),
case Decoded of case Decoded of
#{<<"target_type">> := TargetTypeBin, #{<<"target_type">> := TargetTypeBin, <<"target_id">> := TargetId, <<"reason">> := Reason} ->
<<"target_id">> := TargetId,
<<"reason">> := Reason} ->
TargetType = parse_target_type(TargetTypeBin), TargetType = parse_target_type(TargetTypeBin),
case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of
{ok, Report} -> {ok, Report} ->
Response = report_to_json(Report), Response = handler_utils:report_to_json(Report),
send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, target_not_found} -> {error, target_not_found} ->
send_error(Req2, 404, <<"Target not found">>); handler_utils:send_error(Req2, 404, <<"Target not found">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Missing required fields">>) handler_utils:send_error(Req2, 400, <<"Missing required fields">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% @doc GET /v1/reports — список жалоб (для администратора/модератора).
-spec list_reports(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_reports(Req) -> list_reports(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} -> {ok, AdminId, Req1} ->
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
case {proplists:get_value(<<"target_type">>, Qs), proplists:get_value(<<"target_id">>, Qs)} of case {proplists:get_value(<<"target_type">>, Qs),
proplists:get_value(<<"target_id">>, Qs)} of
{undefined, _} -> {undefined, _} ->
case logic_moderation:get_reports(AdminId) of case logic_moderation:get_reports(AdminId) of
{ok, Reports} -> {ok, Reports} ->
Response = [report_to_json(R) || R <- Reports], Response = [handler_utils:report_to_json(R) || R <- Reports],
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Admin access required">>); handler_utils:send_error(Req1, 403, <<"Admin access required">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{TargetTypeBin, TargetId} -> {TargetTypeBin, TargetId} ->
TargetType = parse_target_type(TargetTypeBin), TargetType = parse_target_type(TargetTypeBin),
case logic_moderation:get_reports_by_target(AdminId, TargetType, TargetId) of case logic_moderation:get_reports_by_target(AdminId, TargetType, TargetId) of
{ok, Reports} -> {ok, Reports} ->
Response = [report_to_json(R) || R <- Reports], Response = [handler_utils:report_to_json(R) || R <- Reports],
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Admin access required">>); handler_utils:send_error(Req1, 403, <<"Admin access required">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end end
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
parse_target_type(<<"event">>) -> event; %%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Преобразует бинарное имя типа в атом.
%% Поддерживаются только 'event' и 'calendar'.
-spec parse_target_type(binary()) -> event | calendar | review | undefined.
parse_target_type(<<"event">>) -> event;
parse_target_type(<<"calendar">>) -> calendar; parse_target_type(<<"calendar">>) -> calendar;
parse_target_type(_) -> undefined. parse_target_type(<<"review">>) -> review;
parse_target_type(_) -> undefined.
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 => case Report#report.resolved_at of
undefined -> null;
Dt -> datetime_to_iso8601(Dt)
end,
resolved_by => Report#report.resolved_by
}.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
Req1 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Req1, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
Req1 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Req1, []}.

View File

@@ -1,42 +1,149 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик конкретного отзыва (клиентский API).
%%%
%%% GET получить отзыв по ID.
%%% PUT обновить отзыв (владельцем).
%%% DELETE удалить отзыв (владельцем).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_review_by_id). -module(handler_review_by_id).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
BaseParams = [
#{
name => <<"id">>,
in => <<"path">>,
description => <<"Review ID">>,
required => true,
schema => #{type => string}
}
],
[
#{ % GET by id
path => <<"/v1/reviews/:id">>,
method => <<"GET">>,
description => <<"Get review by ID">>,
tags => [<<"Reviews">>],
parameters => BaseParams,
responses => #{
200 => #{
description => <<"Review details">>,
content => #{<<"application/json">> => #{schema => review_schema()}}
},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Review not found">>}
}
},
#{ % PUT update
path => <<"/v1/reviews/:id">>,
method => <<"PUT">>,
description => <<"Update review">>,
tags => [<<"Reviews">>],
parameters => BaseParams,
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => review_update_schema()}}
},
responses => #{
200 => #{description => <<"Review updated">>},
400 => #{description => <<"Invalid request">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Review not found">>}
}
},
#{ % DELETE
path => <<"/v1/reviews/:id">>,
method => <<"DELETE">>,
description => <<"Delete review">>,
tags => [<<"Reviews">>],
parameters => BaseParams,
responses => #{
200 => #{description => <<"Review deleted">>},
403 => #{description => <<"Access denied">>},
404 => #{description => <<"Review not found">>}
}
}
].
review_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
user_id => #{type => string},
target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]},
target_id => #{type => string},
rating => #{type => integer, minimum => 1, maximum => 5},
comment => #{type => string},
status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]},
reason => #{type => string, nullable => true},
likes => #{type => integer},
dislikes => #{type => integer},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
review_update_schema() ->
#{
type => object,
properties => #{
rating => #{type => integer, minimum => 1, maximum => 5},
comment => #{type => string}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_review(Req); <<"GET">> -> get_review(Req);
<<"PUT">> -> update_review(Req); <<"PUT">> -> update_review(Req);
<<"DELETE">> -> delete_review(Req); <<"DELETE">> -> delete_review(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/reviews/:id - получение отзыва %% @doc GET /v1/reviews/:id получение отзыва.
-spec get_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_review(Req) -> get_review(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
ReviewId = cowboy_req:binding(id, Req1), ReviewId = cowboy_req:binding(id, Req1),
case logic_review:get_review(UserId, ReviewId) of case logic_review:get_review(UserId, ReviewId) of
{ok, Review} -> {ok, Review} ->
Response = review_to_json(Review), handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review));
send_json(Req1, 200, Response);
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Review not found">>); handler_utils:send_error(Req1, 404, <<"Review not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% PUT /v1/reviews/:id - обновление отзыва %% @doc PUT /v1/reviews/:id обновление отзыва.
-spec update_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
update_review(Req) -> update_review(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
ReviewId = cowboy_req:binding(id, Req1), ReviewId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
@@ -45,74 +152,44 @@ update_review(Req) ->
Updates = maps:to_list(UpdatesMap), Updates = maps:to_list(UpdatesMap),
case logic_review:update_review(UserId, ReviewId, Updates) of case logic_review:update_review(UserId, ReviewId, Updates) of
{ok, _} -> {ok, _} ->
% Получаем обновлённый отзыв из базы
case core_review:get_by_id(ReviewId) of case core_review:get_by_id(ReviewId) of
{ok, Updated} -> {ok, Updated} ->
Response = review_to_json(Updated), handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated));
send_json(Req2, 200, Response);
_ -> _ ->
send_error(Req2, 500, <<"Failed to retrieve updated review">>) handler_utils:send_error(Req2, 500, <<"Failed to retrieve updated review">>)
end; end;
{error, access_denied} -> {error, access_denied} ->
send_error(Req2, 403, <<"Access denied">>); handler_utils:send_error(Req2, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req2, 404, <<"Review not found">>); handler_utils:send_error(Req2, 404, <<"Review not found">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% DELETE /v1/reviews/:id - удаление отзыва %% @doc DELETE /v1/reviews/:id удаление отзыва.
-spec delete_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
delete_review(Req) -> delete_review(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
ReviewId = cowboy_req:binding(id, Req1), ReviewId = cowboy_req:binding(id, Req1),
case logic_review:delete_review(UserId, ReviewId) of case logic_review:delete_review(UserId, ReviewId) of
{ok, deleted} -> {ok, deleted} ->
send_json(Req1, 200, #{status => <<"deleted">>}); handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
{error, access_denied} -> {error, access_denied} ->
send_error(Req1, 403, <<"Access denied">>); handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} -> {error, not_found} ->
send_error(Req1, 404, <<"Review not found">>); handler_utils:send_error(Req1, 404, <<"Review not found">>);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции
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,
created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_at)
}.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,109 +1,190 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик отзывов (клиентский API).
%%%
%%% POST создание нового отзыва.
%%% GET получение списка отзывов для указанной цели.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_reviews). -module(handler_reviews).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % POST create review
path => <<"/v1/reviews">>,
method => <<"POST">>,
description => <<"Create a new review">>,
tags => [<<"Reviews">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"target_type">>, <<"target_id">>, <<"rating">>, <<"comment">>],
properties => #{
target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]},
target_id => #{type => string},
rating => #{type => integer, minimum => 1, maximum => 5},
comment => #{type => string}
}
}}}
},
responses => #{
201 => #{description => <<"Review created">>},
400 => #{description => <<"Missing required fields or invalid JSON">>},
403 => #{description => <<"Cannot review this target">>},
404 => #{description => <<"Target not found">>},
409 => #{description => <<"Already reviewed">>}
}
},
#{ % GET list reviews
path => <<"/v1/reviews">>,
method => <<"GET">>,
description => <<"List reviews for a target">>,
tags => [<<"Reviews">>],
parameters => [
#{
name => <<"target_type">>,
in => <<"query">>,
description => <<"calendar or event">>,
required => true,
schema => #{type => string, enum => [<<"calendar">>, <<"event">>]}
},
#{
name => <<"target_id">>,
in => <<"query">>,
description => <<"ID of the target">>,
required => true,
schema => #{type => string}
}
],
responses => #{
200 => #{
description => <<"Array of reviews">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => review_schema()
}}}
},
400 => #{description => <<"Missing target_type or target_id">>}
}
}
].
review_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
user_id => #{type => string},
target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]},
target_id => #{type => string},
rating => #{type => integer, minimum => 1, maximum => 5},
comment => #{type => string},
status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]},
reason => #{type => string, nullable => true},
likes => #{type => integer},
dislikes => #{type => integer},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"POST">> -> create_review(Req); <<"POST">> -> create_review(Req);
<<"GET">> -> list_reviews(Req); <<"GET">> -> list_reviews(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% POST /v1/reviews - создание отзыва %% @doc POST /v1/reviews создание отзыва.
-spec create_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_review(Req) -> create_review(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
Decoded when is_map(Decoded) -> Decoded when is_map(Decoded) ->
case Decoded of case Decoded of
#{<<"target_type">> := TargetTypeBin, #{<<"target_type">> := TargetTypeBin,
<<"target_id">> := TargetId, <<"target_id">> := TargetId,
<<"rating">> := Rating, <<"rating">> := Rating,
<<"comment">> := Comment} -> <<"comment">> := Comment} ->
TargetType = parse_target_type(TargetTypeBin), TargetType = parse_target_type(TargetTypeBin),
case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of
{ok, Review} -> {ok, Review} ->
Response = review_to_json(Review), Response = handler_utils:review_to_json(Review),
send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, already_reviewed} -> {error, already_reviewed} ->
send_error(Req2, 409, <<"Already reviewed">>); handler_utils:send_error(Req2, 409, <<"Already reviewed">>);
{error, cannot_review} -> {error, cannot_review} ->
send_error(Req2, 403, <<"Cannot review this target">>); handler_utils:send_error(Req2, 403, <<"Cannot review this target">>);
{error, target_not_found} -> {error, target_not_found} ->
send_error(Req2, 404, <<"Target not found">>); handler_utils:send_error(Req2, 404, <<"Target not found">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Missing required fields">>) handler_utils:send_error(Req2, 400, <<"Missing required fields: target_type, target_id, rating, comment">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% GET /v1/reviews - список отзывов для цели %% @doc GET /v1/reviews список отзывов для цели.
-spec list_reviews(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_reviews(Req) -> list_reviews(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
case {proplists:get_value(<<"target_type">>, Qs), proplists:get_value(<<"target_id">>, Qs)} of case {proplists:get_value(<<"target_type">>, Qs),
proplists:get_value(<<"target_id">>, Qs)} of
{undefined, _} -> {undefined, _} ->
send_error(Req1, 400, <<"Missing target_type">>); handler_utils:send_error(Req1, 400, <<"Missing target_type">>);
{_, undefined} -> {_, undefined} ->
send_error(Req1, 400, <<"Missing target_id">>); handler_utils:send_error(Req1, 400, <<"Missing target_id">>);
{TargetTypeBin, TargetId} -> {TargetTypeBin, TargetId} ->
TargetType = parse_target_type(TargetTypeBin), TargetType = parse_target_type(TargetTypeBin),
case logic_review:list_reviews(UserId, TargetType, TargetId) of case logic_review:list_reviews(UserId, TargetType, TargetId) of
{ok, Reviews} -> {ok, Reviews} ->
Response = [review_to_json(R) || R <- Reviews], Response = [handler_utils:review_to_json(R) || R <- Reviews],
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end end
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
parse_target_type(<<"event">>) -> event; %%% Внутренние функции
%%%===================================================================
%% @private Преобразует бинарное имя типа в атом.
-spec parse_target_type(binary()) -> event | calendar | undefined.
parse_target_type(<<"event">>) -> event;
parse_target_type(<<"calendar">>) -> calendar; parse_target_type(<<"calendar">>) -> calendar;
parse_target_type(_) -> undefined. parse_target_type(_) -> undefined.
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,
created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_at)
}.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,106 +1,143 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик полнотекстового поиска (клиентский API).
%%%
%%% GET выполняет поиск по календарям и событиям.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_search). -module(handler_search).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/search">>,
method => <<"GET">>,
description => <<"Search calendars and events">>,
tags => [<<"Search">>],
parameters => [
#{name => <<"type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>]}, description => <<"Type of entities to search">>},
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search query">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Maximum results per page">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset for pagination">>},
#{name => <<"tags">>, in => <<"query">>, schema => #{type => string}, description => <<"Comma-separated tags">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"start_time">>, <<"created_at">>, <<"title">>]}, description => <<"Field to sort by">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort order">>},
#{name => <<"lat">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Latitude for geo search">>},
#{name => <<"lon">>, in => <<"query">>, schema => #{type => number, format => float}, description => <<"Longitude for geo search">>},
#{name => <<"radius">>, in => <<"query">>, schema => #{type => integer}, description => <<"Radius in km for geo search">>},
#{name => <<"from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start datetime (ISO8601)">>},
#{name => <<"to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End datetime (ISO8601)">>}
],
responses => #{
200 => #{
description => <<"Search results with pagination">>,
content => #{<<"application/json">> => #{schema => #{
type => object,
properties => #{
total => #{type => integer},
limit => #{type => integer},
offset => #{type => integer},
results => #{type => array, items => #{type => object}}
}
}}}
},
400 => #{description => <<"Invalid parameters">>},
500 => #{description => <<"Search failed">>}
}
}
].
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> search(Req); <<"GET">> -> search(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% @doc GET /v1/search — полнотекстовый поиск с фильтрами.
-spec search(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
search(Req) -> search(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
Qs = cowboy_req:parse_qs(Req1), Qs = cowboy_req:parse_qs(Req1),
Type = proplists:get_value(<<"type">>, Qs, undefined),
Type = proplists:get_value(<<"type">>, Qs, undefined),
Query = proplists:get_value(<<"q">>, Qs, undefined), Query = proplists:get_value(<<"q">>, Qs, undefined),
Params = parse_params(Qs), Params = parse_params(Qs),
case logic_search:search(Type, Query, UserId, Params) of case logic_search:search(Type, Query, UserId, Params) of
{ok, Total, Results} -> {ok, Total, Results} ->
Response = #{ Response = #{
total => Total, total => Total,
limit => maps:get(limit, Params, 20), limit => maps:get(limit, Params, 20),
offset => maps:get(offset, Params, 0), offset => maps:get(offset, Params, 0),
results => Results results => Results
}, },
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Search failed">>) handler_utils:send_error(Req1, 500, <<"Search failed">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Собирает карту параметров для поискового движка.
-spec parse_params(cowboy_req:qs()) -> map().
parse_params(Qs) -> parse_params(Qs) ->
Params = #{ Params = #{
limit => parse_int_param(Qs, <<"limit">>, 20), limit => parse_int_param(Qs, <<"limit">>, 20),
offset => parse_int_param(Qs, <<"offset">>, 0), offset => parse_int_param(Qs, <<"offset">>, 0),
tags => proplists:get_value(<<"tags">>, Qs), tags => proplists:get_value(<<"tags">>, Qs),
sort => proplists:get_value(<<"sort">>, Qs, <<"start_time">>), sort => proplists:get_value(<<"sort">>, Qs, <<"start_time">>),
order => proplists:get_value(<<"order">>, Qs, <<"asc">>) order => proplists:get_value(<<"order">>, Qs, <<"asc">>)
}, },
Params1 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of Params1 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
{{ok, Lat}, {ok, Lon}} -> {{ok, Lat}, {ok, Lon}} ->
Radius = parse_int_param(Qs, <<"radius">>, 10), Radius = parse_int_param(Qs, <<"radius">>, 10),
Params#{lat => Lat, lon => Lon, radius => Radius}; Params#{lat => Lat, lon => Lon, radius => Radius};
_ -> Params _ -> Params
end, end,
Params2 = case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of Params2 = case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
{{ok, From}, {ok, To}} -> {{ok, From}, {ok, To}} -> Params1#{from => From, to => To};
Params1#{from => From, to => To}; {{ok, From}, error} -> Params1#{from => From};
{{ok, From}, error} -> {error, {ok, To}} -> Params1#{to => To};
Params1#{from => From}; _ -> Params1
{error, {ok, To}} ->
Params1#{to => To};
_ -> Params1
end, end,
Params2. Params2.
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
parse_int_param(Qs, Key, Default) -> parse_int_param(Qs, Key, Default) ->
case proplists:get_value(Key, Qs) of handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
undefined -> Default;
Val -> binary_to_integer(Val)
end.
-spec parse_float_param(cowboy_req:qs(), binary()) -> {ok, float()} | error.
parse_float_param(Qs, Key) -> parse_float_param(Qs, Key) ->
case proplists:get_value(Key, Qs) of case proplists:get_value(Key, Qs) of
undefined -> error; undefined -> error;
Val -> {ok, binary_to_float(Val)} Val -> {ok, binary_to_float(Val)}
end. end.
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
parse_datetime_param(Qs, Key) -> parse_datetime_param(Qs, Key) ->
case proplists:get_value(Key, Qs) of case proplists:get_value(Key, Qs) of
undefined -> error; undefined -> error;
Val -> Val -> handler_utils:parse_datetime(Val)
try end.
[DateStr, TimeStr] = string:split(Val, "T"),
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"),
[Y, M, D] = [binary_to_integer(X) || X <- string:split(DateStr, "-", all)],
[H, Min, S] = [binary_to_integer(X) || X <- string:split(TimeStrNoZ, ":", all)],
{ok, {{Y, M, D}, {H, Min, S}}}
catch
_:_ -> error
end
end.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,35 +1,114 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик подписки текущего пользователя (клиентский API).
%%%
%%% GET получить информацию о подписке.
%%% POST активировать подписку или начать пробный период.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_subscription). -module(handler_subscription).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{ % GET
path => <<"/v1/subscription">>,
method => <<"GET">>,
description => <<"Get current user subscription">>,
tags => [<<"Subscription">>],
responses => #{
200 => #{
description => <<"Subscription details">>,
content => #{<<"application/json">> => #{schema => subscription_schema()}}
},
401 => #{description => <<"Unauthorized">>}
}
},
#{ % POST
path => <<"/v1/subscription">>,
method => <<"POST">>,
description => <<"Activate subscription or start trial">>,
tags => [<<"Subscription">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"action">>],
properties => #{
action => #{type => string, enum => [<<"start_trial">>, <<"activate">>]},
plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
payment_info => #{type => object, description => <<"Payment information">>}
}
}}}
},
responses => #{
201 => #{description => <<"Subscription activated or trial started">>},
400 => #{description => <<"Invalid action or JSON">>},
402 => #{description => <<"Payment failed">>},
409 => #{description => <<"Already has active subscription">>}
}
}
].
subscription_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
user_id => #{type => string},
plan => #{type => string, enum => [<<"monthly">>, <<"quarterly">>, <<"biannual">>, <<"annual">>]},
status => #{type => string, enum => [<<"active">>, <<"expired">>, <<"cancelled">>]},
trial_used => #{type => boolean},
started_at => #{type => string, format => <<"date-time">>},
expires_at => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> get_subscription(Req); <<"GET">> -> get_subscription(Req);
<<"POST">> -> create_subscription(Req); <<"POST">> -> create_subscription(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/subscription - получить подписку текущего пользователя %% @doc GET /v1/subscription получить подписку текущего пользователя.
-spec get_subscription(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
get_subscription(Req) -> get_subscription(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
case logic_subscription:get_user_subscription(UserId) of case logic_subscription:get_user_subscription(UserId) of
{ok, Subscription} -> {ok, Subscription} ->
send_json(Req1, 200, Subscription); handler_utils:send_json(Req1, 200, subscription_to_json(Subscription));
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% POST /v1/subscription - активировать подписку %% @doc POST /v1/subscription активировать подписку.
-spec create_subscription(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_subscription(Req) -> create_subscription(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
@@ -39,11 +118,11 @@ create_subscription(Req) ->
case logic_subscription:start_trial(UserId) of case logic_subscription:start_trial(UserId) of
{ok, Subscription} -> {ok, Subscription} ->
Response = subscription_to_json(Subscription), Response = subscription_to_json(Subscription),
send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, already_has_subscription} -> {error, already_has_subscription} ->
send_error(Req2, 409, <<"Already has active subscription">>); handler_utils:send_error(Req2, 409, <<"Already has active subscription">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
#{<<"action">> := <<"activate">>, <<"plan">> := PlanBin} -> #{<<"action">> := <<"activate">>, <<"plan">> := PlanBin} ->
Plan = parse_plan(PlanBin), Plan = parse_plan(PlanBin),
@@ -51,54 +130,39 @@ create_subscription(Req) ->
case logic_subscription:activate_subscription(UserId, Plan, PaymentInfo) of case logic_subscription:activate_subscription(UserId, Plan, PaymentInfo) of
{ok, Subscription} -> {ok, Subscription} ->
Response = subscription_to_json(Subscription), Response = subscription_to_json(Subscription),
send_json(Req2, 201, Response); handler_utils:send_json(Req2, 201, Response);
{error, payment_failed} -> {error, payment_failed} ->
send_error(Req2, 402, <<"Payment failed">>); handler_utils:send_error(Req2, 402, <<"Payment failed">>);
{error, _} -> {error, _} ->
send_error(Req2, 500, <<"Internal server error">>) handler_utils:send_error(Req2, 500, <<"Internal server error">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid action. Use 'start_trial' or 'activate'">>) handler_utils:send_error(Req2, 400, <<"Invalid action. Use 'start_trial' or 'activate'">>)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Invalid JSON">>) handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
catch catch
_:_ -> _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
send_error(Req2, 400, <<"Invalid JSON format">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
parse_plan(<<"monthly">>) -> monthly; %%% Внутренние функции
%%%===================================================================
%% @private Преобразует бинарное имя плана в атом.
-spec parse_plan(binary()) -> monthly | quarterly | biannual | annual.
parse_plan(<<"monthly">>) -> monthly;
parse_plan(<<"quarterly">>) -> quarterly; parse_plan(<<"quarterly">>) -> quarterly;
parse_plan(<<"biannual">>) -> biannual; parse_plan(<<"biannual">>) -> biannual;
parse_plan(<<"annual">>) -> annual; parse_plan(<<"annual">>) -> annual;
parse_plan(_) -> monthly. parse_plan(_) -> monthly.
%% @private Формирует JSON-представление подписки (поддерживает как запись, так и карту).
-spec subscription_to_json(#subscription{} | map()) -> map().
subscription_to_json(Subscription) when is_tuple(Subscription) -> subscription_to_json(Subscription) when is_tuple(Subscription) ->
#{ handler_utils:subscription_to_json(Subscription);
id => Subscription#subscription.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)
};
subscription_to_json(Subscription) when is_map(Subscription) -> subscription_to_json(Subscription) when is_map(Subscription) ->
Subscription. Subscription.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,94 +1,90 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик конкретного тикета (клиентский API).
%%%
%%% GET получить тикет по ID (только для автора).
%%% @end
%%%-------------------------------------------------------------------
-module(handler_ticket_by_id). -module(handler_ticket_by_id).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
init(Req, _Opts) -> -spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
case cowboy_req:method(Req) of init(Req, Opts) ->
<<"GET">> -> get_ticket(Req); handle(Req, Opts).
<<"PUT">> -> update_ticket(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>)
end.
get_ticket(Req) -> -spec trails() -> [map()].
case handler_auth:authenticate(Req) of trails() ->
{ok, UserId, Req1} -> [
TicketId = cowboy_req:binding(id, Req1), #{
io:format("[TICKET_BY_ID] User ~s requests ticket ~s~n", [UserId, TicketId]), path => <<"/v1/tickets/:id">>,
case core_ticket:get_by_id(TicketId) of method => <<"GET">>,
{ok, Ticket} -> description => <<"Get a user's own ticket by ID">>,
io:format("[TICKET_BY_ID] Found ticket, reporter_id: ~s~n", [Ticket#ticket.reporter_id]), tags => [<<"Tickets">>],
case Ticket#ticket.reporter_id =:= UserId of parameters => [
true -> #{
io:format("[TICKET_BY_ID] Access granted~n"), name => <<"id">>,
send_json(Req1, 200, ticket_to_json(Ticket)); in => <<"path">>,
false -> description => <<"Ticket ID">>,
io:format("[TICKET_BY_ID] Access denied~n"), required => true,
send_error(Req1, 403, <<"Access denied">>) schema => #{type => string}
end; }
{error, not_found} -> ],
io:format("[TICKET_BY_ID] Ticket not found~n"), responses => #{
send_error(Req1, 404, <<"Ticket not found">>) 200 => #{
end; description => <<"Ticket details">>,
{error, Code, Message, Req1} -> content => #{<<"application/json">> => #{schema => ticket_schema()}}
io:format("[TICKET_BY_ID] Auth error: ~p - ~s~n", [Code, Message]), },
send_error(Req1, Code, Message) 403 => #{description => <<"Access denied (not the reporter)">>},
end. 404 => #{description => <<"Ticket not found">>}
}
}
].
update_ticket(Req) -> ticket_schema() ->
case handler_auth:authenticate(Req) of
{ok, UserId, Req1} ->
TicketId = cowboy_req:binding(id, Req1),
{ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of
Updates when is_map(Updates) ->
case core_ticket:get_by_id(TicketId) of
{ok, Ticket} ->
case Ticket#ticket.reporter_id =:= UserId of
true ->
case core_ticket:update_ticket(TicketId, Updates) of
{ok, Updated} -> send_json(Req2, 200, ticket_to_json(Updated));
{error, R} -> send_error(Req2, 500, R)
end;
false -> send_error(Req2, 403, <<"Access denied">>)
end;
{error, not_found} -> send_error(Req2, 404, <<"Ticket not found">>)
end;
_ -> send_error(Req2, 400, <<"Invalid JSON">>)
catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end.
ticket_to_json(T) ->
#{ #{
id => T#ticket.id, type => object,
error_hash => T#ticket.error_hash, properties => #{
error_message => T#ticket.error_message, id => #{type => string},
stacktrace => T#ticket.stacktrace, reporter_id => #{type => string},
context => T#ticket.context, error_hash => #{type => string},
count => T#ticket.count, error_message => #{type => string},
first_seen => datetime_to_iso8601(T#ticket.first_seen), stacktrace => #{type => string},
last_seen => datetime_to_iso8601(T#ticket.last_seen), context => #{type => string},
status => T#ticket.status, count => #{type => integer},
assigned_to => T#ticket.assigned_to, first_seen => #{type => string, format => <<"date-time">>},
resolution_note => T#ticket.resolution_note 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}
}
}. }.
datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> %%% HTTP-методы
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", handle(Req, _Opts) ->
[Year, Month, Day, Hour, Minute, Second])); case cowboy_req:method(Req) of
datetime_to_iso8601(undefined) -> undefined. <<"GET">> -> get_ticket(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
send_json(Req, Status, Data) -> -spec get_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
Body = jsx:encode(Data), get_ticket(Req) ->
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), case handler_utils:auth_user(Req) of
{ok, Body, []}. {ok, UserId, Req1} ->
TicketId = cowboy_req:binding(id, Req1),
send_error(Req, Status, Message) -> case logic_ticket:get_user_ticket(UserId, TicketId) of
Body = jsx:encode(#{error => Message}), {ok, Ticket} ->
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), handler_utils:send_json(Req1, 200, handler_utils:ticket_to_json(Ticket));
{ok, Body, []}. {error, access_denied} ->
handler_utils:send_error(Req1, 403, <<"Access denied">>);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Ticket not found">>);
{error, _} ->
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.

View File

@@ -1,82 +1,146 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик пользовательских тикетов (клиентский API).
%%%
%%% GET получить список тикетов.
%%% Администраторы видят все тикеты,
%%% обычные пользователи только свои.
%%% POST создать новый тикет об ошибке.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_tickets). -module(handler_tickets).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
-include("records.hrl"). -include("records.hrl").
init(Req0, Opts) -> %%% cowboy_handler callback
handle(Req0, Opts). -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 a new ticket (bug report)">>,
tags => [<<"Tickets">>],
requestBody => #{
required => true,
content => #{<<"application/json">> => #{schema => #{
type => object,
required => [<<"error_message">>],
properties => #{
error_message => #{type => string},
stacktrace => #{type => string},
context => #{type => string}
}
}}}
},
responses => #{
201 => #{description => <<"Ticket created">>},
400 => #{description => <<"Missing required fields or invalid JSON">>},
401 => #{description => <<"Unauthorized">>}
}
}
].
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}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_tickets(Req); <<"GET">> -> list_tickets(Req);
<<"POST">> -> create_ticket(Req); <<"POST">> -> create_ticket(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% @doc GET /v1/tickets — список тикетов.
-spec list_tickets(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_tickets(Req) -> list_tickets(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
case admin_utils:is_admin(UserId) of case admin_utils:is_admin(UserId) of
true -> true ->
Tickets = core_ticket:list_all(), Tickets = core_ticket:list_all(),
send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]); handler_utils:send_json(Req1, 200, [handler_utils:ticket_to_json(T) || T <- Tickets]);
false -> false ->
Tickets = core_ticket:list_by_user(UserId), Tickets = core_ticket:list_by_user(UserId),
send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]) handler_utils:send_json(Req1, 200, [handler_utils:ticket_to_json(T) || T <- Tickets])
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% @doc POST /v1/tickets — создание тикета.
-spec create_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
create_ticket(Req) -> create_ticket(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
{ok, Body, Req2} = cowboy_req:read_body(Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1),
try jsx:decode(Body, [return_maps]) of try jsx:decode(Body, [return_maps]) of
#{<<"error_message">> := _} = Data -> #{<<"error_message">> := _} = Data ->
TicketData = maps:merge(#{<<"reporter_id">> => UserId, <<"status">> => <<"open">>}, Data), TicketData = maps:merge(
#{<<"reporter_id">> => UserId, <<"status">> => <<"open">>},
Data
),
case core_ticket:create_ticket(TicketData) of case core_ticket:create_ticket(TicketData) of
{ok, Ticket} -> {ok, Ticket} ->
send_json(Req2, 201, ticket_to_json(Ticket)); handler_utils:send_json(Req2, 201, handler_utils:ticket_to_json(Ticket));
{error, Reason} -> {error, Reason} ->
send_error(Req2, 500, Reason) handler_utils:send_error(Req2, 500, Reason)
end; end;
_ -> _ ->
send_error(Req2, 400, <<"Missing 'error_message' field">>) handler_utils:send_error(Req2, 400, <<"Missing 'error_message' field">>)
catch catch
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>) _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
ticket_to_json(T) ->
#{
id => T#ticket.id,
error_hash => T#ticket.error_hash,
error_message => T#ticket.error_message,
stacktrace => T#ticket.stacktrace,
context => T#ticket.context,
count => T#ticket.count,
first_seen => datetime_to_iso8601(T#ticket.first_seen),
last_seen => datetime_to_iso8601(T#ticket.last_seen),
status => T#ticket.status,
assigned_to => T#ticket.assigned_to,
resolution_note => T#ticket.resolution_note
}.
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]));
datetime_to_iso8601(undefined) -> undefined.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,57 +1,105 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик бронирований текущего пользователя (клиентский API).
%%% GET возвращает список всех бронирований, сделанных пользователем.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_user_bookings). -module(handler_user_bookings).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/user/bookings">>,
method => <<"GET">>,
description => <<"List bookings of the current user">>,
tags => [<<"Bookings">>],
responses => #{
200 => #{
description => <<"Array of bookings">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => booking_schema()
}}}
},
401 => #{description => <<"Unauthorized">>}
}
}
].
booking_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
event_id => #{type => string},
user_id => #{type => string},
status => #{type => string, enum => [<<"pending">>, <<"confirmed">>, <<"cancelled">>]},
notes => #{type => string, nullable => true},
reminder_sent => #{type => boolean},
confirmed_at => #{type => string, format => <<"date-time">>, nullable => true},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_user_bookings(Req); <<"GET">> -> list_user_bookings(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/user/bookings - список бронирований текущего пользователя %% @doc GET /v1/user/bookings список бронирований текущего пользователя.
-spec list_user_bookings(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_user_bookings(Req) -> list_user_bookings(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
case logic_booking:list_user_bookings(UserId) of case logic_booking:list_user_bookings(UserId) of
{ok, Bookings} -> {ok, Bookings} ->
Response = [booking_to_json(B) || B <- Bookings], Response = [booking_to_json(B) || B <- Bookings],
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции %%%===================================================================
%%% Внутренние функции
%%%===================================================================
%% @private Формирует JSON-представление записи #booking{}.
-spec booking_to_json(#booking{}) -> map().
booking_to_json(Booking) -> booking_to_json(Booking) ->
#{ #{
id => Booking#booking.id, id => Booking#booking.id,
event_id => Booking#booking.event_id, event_id => Booking#booking.event_id,
user_id => Booking#booking.user_id, user_id => Booking#booking.user_id,
status => Booking#booking.status, status => Booking#booking.status,
confirmed_at => case Booking#booking.confirmed_at of notes => Booking#booking.notes,
undefined -> null; reminder_sent => Booking#booking.reminder_sent,
Dt -> datetime_to_iso8601(Dt) confirmed_at => case Booking#booking.confirmed_at of
end, undefined -> null;
created_at => datetime_to_iso8601(Booking#booking.created_at), Dt -> handler_utils:datetime_to_iso8601(Dt)
updated_at => datetime_to_iso8601(Booking#booking.updated_at) end,
}. created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,56 +1,87 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик профиля текущего пользователя (клиентский API).
%%%
%%% GET получить информацию о своём профиле.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_user_me). -module(handler_user_me).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
-export([trails/0]).
init(Req, Opts) -> handle(Req, Opts). -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() ->
[
#{
path => <<"/v1/user/me">>,
method => <<"GET">>,
description => <<"Get current user profile">>,
tags => [<<"Users">>],
responses => #{
200 => #{
description => <<"User profile">>,
content => #{<<"application/json">> => #{schema => user_schema()}}
},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"User not found">>}
}
}
].
user_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
email => #{type => string, format => <<"email">>},
role => #{type => string, enum => [<<"user">>, <<"bot">>]},
status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]},
reason => #{type => string, nullable => true},
nickname => #{type => string, nullable => true},
avatar_url => #{type => string, nullable => true},
timezone => #{type => string, nullable => true},
language => #{type => string, nullable => true},
social_links => #{type => array, items => #{type => string}, nullable => true},
phone => #{type => string, nullable => true},
preferences => #{type => object, nullable => true},
last_login => #{type => string, format => <<"date-time">>},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> <<"GET">> -> get_me(Req);
case authenticate(Req) of _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
{ok, UserId, Req1} ->
case core_user:get_by_id(UserId) of
{ok, User} ->
Response = #{
id => User#user.id,
email => User#user.email,
role => User#user.role,
status => User#user.status,
created_at => User#user.created_at,
updated_at => User#user.updated_at
},
send_json(Req1, 200, Response);
{error, not_found} ->
send_error(Req1, 404, <<"User not found">>)
end;
{error, Code, Message, Req1} ->
send_error(Req1, Code, Message)
end;
_ ->
send_error(Req, 405, <<"Method not allowed">>)
end. end.
authenticate(Req) -> %% @doc GET /v1/user/me — получение профиля текущего пользователя.
case cowboy_req:parse_header(<<"authorization">>, Req) of -spec get_me(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
{bearer, Token} -> get_me(Req) ->
case logic_auth:verify_jwt(Token) of case handler_utils:auth_user(Req) of
{ok, UserId, _Role} -> % ← теперь возвращается {ok, UserId, Role} {ok, UserId, Req1} ->
{ok, UserId, Req}; case core_user:get_by_id(UserId) of
{error, expired} -> {ok, User} ->
{error, 401, <<"Token expired">>, Req}; handler_utils:send_json(Req1, 200, handler_utils:user_to_json(User));
{error, _} -> {error, not_found} ->
{error, 401, <<"Invalid token">>, Req} handler_utils:send_error(Req1, 404, <<"User not found">>)
end; end;
_ -> {error, Code, Message, Req1} ->
{error, 401, <<"Missing or invalid Authorization header">>, Req} handler_utils:send_error(Req1, Code, Message)
end. end.
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -1,56 +1,86 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик отзывов текущего пользователя (клиентский API).
%%% GET возвращает список всех отзывов, оставленных пользователем.
%%% @end
%%%-------------------------------------------------------------------
-module(handler_user_reviews). -module(handler_user_reviews).
-include("records.hrl"). -behaviour(cowboy_handler).
-export([init/2]). -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) -> init(Req, Opts) ->
handle(Req, Opts). handle(Req, Opts).
%%% Swagger metadata
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/user/reviews">>,
method => <<"GET">>,
description => <<"List reviews of the current user">>,
tags => [<<"Reviews">>],
responses => #{
200 => #{
description => <<"Array of reviews">>,
content => #{<<"application/json">> => #{schema => #{
type => array,
items => review_schema()
}}}
},
401 => #{description => <<"Unauthorized">>}
}
}
].
review_schema() ->
#{
type => object,
properties => #{
id => #{type => string},
user_id => #{type => string},
target_type => #{type => string, enum => [<<"calendar">>, <<"event">>]},
target_id => #{type => string},
rating => #{type => integer, minimum => 1, maximum => 5},
comment => #{type => string},
status => #{type => string, enum => [<<"visible">>, <<"hidden">>, <<"deleted">>]},
reason => #{type => string, nullable => true},
likes => #{type => integer},
dislikes => #{type => integer},
created_at => #{type => string, format => <<"date-time">>},
updated_at => #{type => string, format => <<"date-time">>}
}
}.
%%%===================================================================
%%% HTTP-методы
%%%===================================================================
%% @private
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
handle(Req, _Opts) -> handle(Req, _Opts) ->
case cowboy_req:method(Req) of case cowboy_req:method(Req) of
<<"GET">> -> list_user_reviews(Req); <<"GET">> -> list_user_reviews(Req);
_ -> send_error(Req, 405, <<"Method not allowed">>) _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end. end.
%% GET /v1/user/reviews - список отзывов текущего пользователя %% @doc GET /v1/user/reviews список отзывов текущего пользователя.
-spec list_user_reviews(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_user_reviews(Req) -> list_user_reviews(Req) ->
case handler_auth:authenticate(Req) of case handler_utils:auth_user(Req) of
{ok, UserId, Req1} -> {ok, UserId, Req1} ->
case logic_review:list_user_reviews(UserId) of case logic_review:list_user_reviews(UserId) of
{ok, Reviews} -> {ok, Reviews} ->
Response = [review_to_json(R) || R <- Reviews], Response = [handler_utils:review_to_json(R) || R <- Reviews],
send_json(Req1, 200, Response); handler_utils:send_json(Req1, 200, Response);
{error, _} -> {error, _} ->
send_error(Req1, 500, <<"Internal server error">>) handler_utils:send_error(Req1, 500, <<"Internal server error">>)
end; end;
{error, Code, Message, Req1} -> {error, Code, Message, Req1} ->
send_error(Req1, Code, Message) handler_utils:send_error(Req1, Code, Message)
end. end.
%% Вспомогательные функции
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,
created_at => datetime_to_iso8601(Review#review.created_at),
updated_at => datetime_to_iso8601(Review#review.updated_at)
}.
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])).
send_json(Req, Status, Data) ->
Body = jsx:encode(Data),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.
send_error(Req, Status, Message) ->
Body = jsx:encode(#{error => Message}),
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
{ok, Body, []}.

View File

@@ -17,6 +17,7 @@
parse_int_qs/2, parse_int_qs/2,
parse_datetime_qs/1, parse_datetime_qs/1,
parse_datetime/1, parse_datetime/1,
datetime_to_iso8601/1,
event_to_json/1, event_to_json/1,
user_to_json/1, user_to_json/1,
review_to_json/1, review_to_json/1,
@@ -117,18 +118,31 @@ parse_datetime_qs(Bin) ->
-spec parse_datetime(binary()) -> {ok, calendar:datetime()} | {error, invalid_format}. -spec parse_datetime(binary()) -> {ok, calendar:datetime()} | {error, invalid_format}.
parse_datetime(Str) -> parse_datetime(Str) ->
try try
[DateStr, TimeStr] = string:split(Str, "T"), %% Убираем завершающий 'Z', если он есть
TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), Clean = case binary:last(Str) of
[YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), $Z -> binary_part(Str, 0, byte_size(Str) - 1);
[HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), _ -> Str
Year = binary_to_integer(list_to_binary(YearStr)), end,
Month = binary_to_integer(list_to_binary(MonthStr)), %% Разделяем дату и время
Day = binary_to_integer(list_to_binary(DayStr)), [DatePart, TimePart] = binary:split(Clean, <<"T">>),
Hour = binary_to_integer(list_to_binary(HourStr)), %% Парсим дату YYYY-MM-DD
Minute = binary_to_integer(list_to_binary(MinuteStr)), [YearStr, MonthStr, DayStr] = binary:split(DatePart, <<"-">>, [global]),
Second = binary_to_integer(list_to_binary(SecondStr)), %% Убираем дробные секунды, если есть
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}}} {ok, {{Year, Month, Day}, {Hour, Minute, Second}}}
catch _:_ -> {error, invalid_format} catch
_:_ -> {error, invalid_format}
end. end.
%%%=================================================================== %%%===================================================================

View File

@@ -1,12 +1,32 @@
%%%-------------------------------------------------------------------
%%% @doc Обработчик документации Swagger.
%%%
%%% Раздаёт Swagger UI и спецификации OpenAPI для
%%% административного и клиентского API.
%%%
%%% GET / индексная страница с выбором API
%%% GET /admin/ Swagger UI для административного API
%%% GET /admin/swagger.json OpenAPI-спецификация (admin)
%%% GET /client/ Swagger UI для клиентского API
%%% GET /client/swagger.json OpenAPI-спецификация (client)
%%% @end
%%%-------------------------------------------------------------------
-module(swagger_docs_handler). -module(swagger_docs_handler).
-behaviour(cowboy_handler). -behaviour(cowboy_handler).
-export([init/2]). -export([init/2]).
%%% cowboy_handler callback
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) -> init(Req, _Opts) ->
Path = cowboy_req:path(Req), Path = cowboy_req:path(Req),
handle(Path, Req). handle(Path, Req).
%%--------------------------------------------------------------------
%% Роутинг путей
%%--------------------------------------------------------------------
-spec handle(binary(), cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
handle(<<"/">>, Req) -> handle(<<"/">>, Req) ->
serve_index(Req); serve_index(Req);
handle(<<"/admin">>, Req) -> handle(<<"/admin">>, Req) ->
@@ -25,7 +45,11 @@ handle(_, Req) ->
cowboy_req:reply(404, #{}, <<"Not Found">>, Req), cowboy_req:reply(404, #{}, <<"Not Found">>, Req),
{ok, [], []}. {ok, [], []}.
%% Главная страница с выбором API %%--------------------------------------------------------------------
%% Главная страница
%%--------------------------------------------------------------------
-spec serve_index(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
serve_index(Req) -> serve_index(Req) ->
Html = <<"<!DOCTYPE html> Html = <<"<!DOCTYPE html>
<html> <html>
@@ -41,7 +65,11 @@ serve_index(Req) ->
cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Html, Req), cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Html, Req),
{ok, Html, []}. {ok, Html, []}.
%% Swagger UI для конкретного API %%--------------------------------------------------------------------
%% Swagger UI
%%--------------------------------------------------------------------
-spec serve_ui(admin | client, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
serve_ui(Api, Req) -> serve_ui(Api, Req) ->
{Title, SpecUrl} = case Api of {Title, SpecUrl} = case Api of
admin -> {<<"EventHub Admin API">>, <<"/admin/swagger.json">>}; admin -> {<<"EventHub Admin API">>, <<"/admin/swagger.json">>};
@@ -59,7 +87,11 @@ serve_ui(Api, Req) ->
cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Html, Req), cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Html, Req),
{ok, Html, []}. {ok, Html, []}.
%% Генерация OpenAPI JSON %%--------------------------------------------------------------------
%% OpenAPI JSON
%%--------------------------------------------------------------------
-spec serve_json(admin | client, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
serve_json(Api, Req) -> serve_json(Api, Req) ->
Trails = case Api of Trails = case Api of
admin -> trails:admin(); admin -> trails:admin();
@@ -81,6 +113,11 @@ serve_json(Api, Req) ->
cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Json, Req), cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Json, Req),
{ok, Json, []}. {ok, Json, []}.
%%--------------------------------------------------------------------
%% Вспомогательные функции
%%--------------------------------------------------------------------
-spec build_paths([map()]) -> map().
build_paths(Trails) -> build_paths(Trails) ->
lists:foldl(fun(Trail, Acc) -> lists:foldl(fun(Trail, Acc) ->
Path = maps:get(path, Trail, <<"/">>), Path = maps:get(path, Trail, <<"/">>),
@@ -91,6 +128,7 @@ build_paths(Trails) ->
maps:merge_with(fun(_, V1, V2) -> maps:merge(V1, V2) end, Acc, #{Path => PathItem}) maps:merge_with(fun(_, V1, V2) -> maps:merge(V1, V2) end, Acc, #{Path => PathItem})
end, #{}, Trails). end, #{}, Trails).
-spec redirect_to_slash(binary(), cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
redirect_to_slash(Location, Req) -> redirect_to_slash(Location, Req) ->
cowboy_req:reply(301, #{<<"location">> => Location}, <<>>, Req), cowboy_req:reply(301, #{<<"location">> => Location}, <<>>, Req),
{ok, [], []}. {ok, [], []}.

View File

@@ -1,5 +1,18 @@
%%%-------------------------------------------------------------------
%%% @doc Пользовательский WebSocket-обработчик.
%%%
%%% Устанавливает WebSocket-соединение после проверки JWT-токена
%%% и подписывает пользователя на обновления календарей.
%%%
%%% Поддерживаемые действия:
%%% - subscribe подписаться на обновления календаря
%%% - unsubscribe отписаться от обновлений
%%% - ping проверка соединения
%%% @end
%%%-------------------------------------------------------------------
-module(ws_handler). -module(ws_handler).
-behaviour(cowboy_websocket). -behaviour(cowboy_websocket).
-export([init/2]). -export([init/2]).
-export([websocket_init/1]). -export([websocket_init/1]).
-export([websocket_handle/2]). -export([websocket_handle/2]).
@@ -7,59 +20,73 @@
-export([terminate/3]). -export([terminate/3]).
-record(state, { -record(state, {
user_id :: binary() | undefined, user_id :: binary() | undefined,
subscriptions = [] :: [binary()] subscriptions = [] :: [binary()] % ← инициализация пустым списком
}). }).
%%%===================================================================
%%% cowboy_websocket callback
%%%===================================================================
-spec init(cowboy_req:req(), any()) ->
{ok, cowboy_req:req(), undefined} |
{cowboy_websocket, cowboy_req:req(), #state{}}.
init(Req, _Opts) -> init(Req, _Opts) ->
Qs = cowboy_req:parse_qs(Req), Qs = cowboy_req:parse_qs(Req),
case proplists:get_value(<<"token">>, Qs) of case proplists:get_value(<<"token">>, Qs) of
undefined -> undefined ->
{ok, cowboy_req:reply(401, #{}, <<"Missing token">>, Req), undefined}; Req1 = cowboy_req:reply(401, #{}, <<"Missing token">>, Req),
{ok, Req1, undefined};
Token -> Token ->
case logic_auth:verify_jwt(Token) of case logic_auth:verify_jwt(Token) of
{ok, UserId, _Role} -> {ok, UserId, _Role} ->
{cowboy_websocket, Req, #state{user_id = UserId}}; {cowboy_websocket, Req, #state{user_id = UserId}};
{error, _} -> {error, _} ->
{ok, cowboy_req:reply(401, #{}, <<"Invalid token">>, Req), undefined} Req1 = cowboy_req:reply(401, #{}, <<"Invalid token">>, Req),
{ok, Req1, undefined}
end end
end. end.
websocket_init(State) -> %%%===================================================================
pg:join(eventhub_ws, self()), %%% websocket callbacks
{ok, State}. %%%===================================================================
-spec websocket_init(#state{}) -> {ok, #state{}}.
websocket_init(#state{user_id = UserId} = State) ->
pg:join(eventhub_ws, self()),
io:format("[WS] User ~s connected~n", [UserId]),
{ok, State#state{subscriptions = []}}.
-spec websocket_handle(term(), #state{}) ->
{ok, #state{}} | {reply, {text, binary()}, #state{}}.
websocket_handle({text, Msg}, State) -> websocket_handle({text, Msg}, State) ->
io:format("WebSocket received: ~s~n", [Msg]), io:format("[WS] Received: ~s~n", [Msg]),
try jsx:decode(Msg, [return_maps]) of try jsx:decode(Msg, [return_maps]) of
#{<<"action">> := <<"subscribe">>, <<"calendar_id">> := CalendarId} -> #{<<"action">> := <<"subscribe">>, <<"calendar_id">> := CalendarId} ->
io:format("Subscribe to calendar: ~s~n", [CalendarId]), handle_subscribe(CalendarId, State);
NewSubs = case lists:member(CalendarId, State#state.subscriptions) of #{<<"action">> := <<"unsubscribe">>, <<"calendar_id">> := CalendarId} ->
true -> State#state.subscriptions; handle_unsubscribe(CalendarId, State);
false -> [CalendarId | State#state.subscriptions]
end,
Reply = jsx:encode(#{status => <<"subscribed">>, calendar_id => CalendarId}),
io:format("Sending reply: ~s~n", [Reply]),
{reply, {text, Reply}, State#state{subscriptions = NewSubs}};
#{<<"action">> := <<"ping">>} -> #{<<"action">> := <<"ping">>} ->
{reply, {text, <<"{\"status\":\"pong\"}">>}, State}; {reply, {text, <<"{\"status\":\"pong\"}">>}, State};
Other -> Other ->
io:format("Unknown action: ~p~n", [Other]), io:format("[WS] Unknown action: ~p~n", [Other]),
{ok, State} {ok, State}
catch catch
_:Error -> _:Error ->
io:format("Error parsing WebSocket message: ~p~n", [Error]), io:format("[WS] Error parsing message: ~p~n", [Error]),
{ok, State} {ok, State}
end; end;
websocket_handle(_Frame, State) -> websocket_handle(_Frame, State) ->
{ok, State}. {ok, State}.
-spec websocket_info(term(), #state{}) ->
{ok, #state{}} | {reply, {text, binary()}, #state{}}.
websocket_info({notification, Type, Data}, State) -> websocket_info({notification, Type, Data}, State) ->
case should_notify(Type, Data, State) of case should_notify(Type, Data, State) of
true -> true ->
Msg = jsx:encode(#{ Msg = jsx:encode(#{
type => Type, type => Type,
data => Data, data => Data,
timestamp => os:system_time(seconds) timestamp => os:system_time(seconds)
}), }),
{reply, {text, Msg}, State}; {reply, {text, Msg}, State};
@@ -69,15 +96,41 @@ websocket_info({notification, Type, Data}, State) ->
websocket_info(_Info, State) -> websocket_info(_Info, State) ->
{ok, State}. {ok, State}.
terminate(_Reason, _Req, _State) -> -spec terminate(term(), cowboy_req:req(), #state{}) -> ok.
terminate(_Reason, _Req, #state{user_id = UserId}) ->
pg:leave(eventhub_ws, self()), pg:leave(eventhub_ws, self()),
io:format("[WS] User ~s disconnected~n", [UserId]),
ok. ok.
should_notify(calendar_update, #{calendar_id := CalId}, State) -> %%%===================================================================
lists:member(CalId, State#state.subscriptions); %%% Внутренние функции
should_notify(booking_update, #{user_id := UserId}, State) -> %%%===================================================================
UserId =:= State#state.user_id;
should_notify(event_update, #{calendar_id := CalId}, State) -> -spec handle_subscribe(binary(), #state{}) -> {reply, {text, binary()}, #state{}}.
lists:member(CalId, State#state.subscriptions); handle_subscribe(CalendarId, #state{subscriptions = Subs} = State) ->
io:format("[WS] Subscribe to calendar: ~s~n", [CalendarId]),
NewSubs = case lists:member(CalendarId, Subs) of
true -> Subs;
false -> [CalendarId | Subs]
end,
Reply = jsx:encode(#{status => <<"subscribed">>, calendar_id => CalendarId}),
{reply, {text, Reply}, State#state{subscriptions = NewSubs}}.
-spec handle_unsubscribe(binary(), #state{}) -> {reply, {text, binary()}, #state{}}.
handle_unsubscribe(CalendarId, #state{subscriptions = Subs} = State) ->
io:format("[WS] Unsubscribe from calendar: ~s~n", [CalendarId]),
NewSubs = lists:delete(CalendarId, Subs),
Reply = jsx:encode(#{status => <<"unsubscribed">>, calendar_id => CalendarId}),
{reply, {text, Reply}, State#state{subscriptions = NewSubs}}.
-spec should_notify(atom(), map(), #state{}) -> boolean().
should_notify(calendar_update, #{calendar_id := CalId}, #state{subscriptions = Subs}) ->
lists:member(CalId, Subs);
should_notify(booking_update, #{user_id := UserId}, #state{user_id = UserId}) ->
true;
should_notify(booking_update, _, _) ->
false;
should_notify(event_update, #{calendar_id := CalId}, #state{subscriptions = Subs}) ->
lists:member(CalId, Subs);
should_notify(_, _, _) -> should_notify(_, _, _) ->
true. true.

View File

@@ -11,6 +11,7 @@
close_ticket/2, close_ticket/2,
get_statistics/1]). get_statistics/1]).
-export([delete_ticket/2]). -export([delete_ticket/2]).
-export([get_user_ticket/2]).
%% Зарегистрировать ошибку (создать или обновить тикет) %% Зарегистрировать ошибку (создать или обновить тикет)
report_error(ErrorMessage, Stacktrace, Context) -> report_error(ErrorMessage, Stacktrace, Context) ->
@@ -39,6 +40,17 @@ report_error(ErrorMessage, Stacktrace, Context) ->
end end
end. end.
%% Получить тикет, проверяя, что пользователь является репортером
get_user_ticket(UserId, TicketId) ->
case core_ticket:get_by_id(TicketId) of % используем базовую функцию без проверки прав
{ok, Ticket} ->
case Ticket#ticket.reporter_id =:= UserId of
true -> {ok, Ticket};
false -> {error, access_denied}
end;
Error -> Error
end.
%% Получить тикет (только для админов) %% Получить тикет (только для админов)
get_ticket(AdminId, TicketId) -> get_ticket(AdminId, TicketId) ->
case admin_utils:is_admin(AdminId) of case admin_utils:is_admin(AdminId) of

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,28 @@ admin() ->
client() -> client() ->
Modules = [ Modules = [
%% пока пусто; добавьте handler_events, handler_event_by_id и др. handler_health,
handler_register,
handler_login,
handler_refresh,
handler_booking_by_id,
handler_bookings,
handler_calendar_by_id,
handler_calendar_view,
handler_calendars,
handler_event_by_id,
handler_event_occurrences,
handler_events,
handler_reports,
handler_review_by_id,
handler_reviews,
handler_search,
handler_subscription,
handler_ticket_by_id,
handler_tickets,
handler_user_bookings,
handler_user_me,
handler_user_reviews
], ],
lists:flatmap(fun trails_from_module/1, Modules). lists:flatmap(fun trails_from_module/1, Modules).