Рефакторинг обработчиков. Часть 2 #21
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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
|
|
||||||
[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.
|
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, []}.
|
|
||||||
@@ -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;
|
%%% Внутренние функции
|
||||||
parse_plan(<<"quarterly">>) -> quarterly;
|
%%%===================================================================
|
||||||
parse_plan(<<"biannual">>) -> biannual;
|
|
||||||
parse_plan(<<"annual">>) -> annual;
|
|
||||||
parse_plan(_) -> monthly.
|
|
||||||
|
|
||||||
|
%% @private Преобразует бинарное имя плана в атом.
|
||||||
|
-spec parse_plan(binary()) -> monthly | quarterly | biannual | annual.
|
||||||
|
parse_plan(<<"monthly">>) -> monthly;
|
||||||
|
parse_plan(<<"quarterly">>) -> quarterly;
|
||||||
|
parse_plan(<<"biannual">>) -> biannual;
|
||||||
|
parse_plan(<<"annual">>) -> annual;
|
||||||
|
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, []}.
|
|
||||||
@@ -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.
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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, []}.
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|||||||
@@ -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, [], []}.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
2813
src/swagger/admin-swagger.json
Normal file
2813
src/swagger/admin-swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
2746
src/swagger/client-swagger.json
Normal file
2746
src/swagger/client-swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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).
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user