Рефакторинг обработчиков. Часть 2 #21
This commit is contained in:
@@ -9,14 +9,14 @@
|
||||
password_hash :: binary(),
|
||||
role :: user | bot,
|
||||
status :: active | frozen | deleted,
|
||||
reason :: binary() | undefined,
|
||||
nickname :: binary() | undefined,
|
||||
avatar_url :: binary() | undefined,
|
||||
timezone :: binary() | undefined,
|
||||
language :: binary() | undefined,
|
||||
social_links :: [binary()] | undefined,
|
||||
phone :: binary() | undefined,
|
||||
preferences :: map() | undefined,
|
||||
reason :: binary(),
|
||||
nickname :: binary(),
|
||||
avatar_url :: binary() | default,
|
||||
timezone :: binary(),
|
||||
language :: binary(),
|
||||
social_links :: [binary()],
|
||||
phone :: binary(),
|
||||
preferences :: map(),
|
||||
last_login :: calendar:datetime(),
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
@@ -36,12 +36,12 @@
|
||||
password_hash :: binary(),
|
||||
role :: superadmin | admin | moderator | support,
|
||||
status :: active | blocked,
|
||||
nickname :: binary() | undefined,
|
||||
avatar_url :: binary() | undefined,
|
||||
timezone :: binary() | undefined,
|
||||
language :: binary() | undefined,
|
||||
phone :: binary() | undefined,
|
||||
preferences :: map() | undefined,
|
||||
nickname :: binary(),
|
||||
avatar_url :: binary() | default,
|
||||
timezone :: binary(),
|
||||
language :: binary(),
|
||||
phone :: binary(),
|
||||
preferences :: map(),
|
||||
last_login :: calendar:datetime(),
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
@@ -61,18 +61,18 @@
|
||||
owner_id :: binary(),
|
||||
title :: binary(),
|
||||
description :: binary(),
|
||||
short_name :: binary() | undefined,
|
||||
category :: binary() | undefined,
|
||||
color :: binary() | undefined,
|
||||
image_url :: binary() | undefined,
|
||||
settings :: map() | undefined,
|
||||
short_name :: binary(),
|
||||
category :: binary(),
|
||||
color :: binary(),
|
||||
image_url :: binary(),
|
||||
settings :: map(),
|
||||
tags :: [binary()],
|
||||
type :: personal | commercial,
|
||||
confirmation :: auto | manual | {timeout, integer()}, % секунд
|
||||
rating_avg :: float(),
|
||||
rating_count :: non_neg_integer(),
|
||||
status :: active | frozen | deleted,
|
||||
reason :: binary() | undefined,
|
||||
reason :: binary(),
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
@@ -88,7 +88,7 @@
|
||||
calendar_id :: binary(),
|
||||
user_id :: binary(), % id пользователя-специалиста
|
||||
name :: binary(), % отображаемое имя в этом календаре
|
||||
specialization :: [binary()] | undefined, % список специализаций (услуг)
|
||||
specialization :: [binary()], % список специализаций (услуг)
|
||||
status :: active | inactive,
|
||||
added_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
@@ -109,20 +109,20 @@
|
||||
event_type :: single | recurring,
|
||||
start_time :: calendar:datetime(),
|
||||
duration :: integer(), % минуты
|
||||
recurrence_rule :: binary() | undefined,
|
||||
master_id :: binary() | undefined,
|
||||
recurrence_rule :: binary(),
|
||||
master_id :: binary(),
|
||||
is_instance :: boolean(),
|
||||
specialist_id :: binary() | undefined,
|
||||
location :: #location{} | undefined,
|
||||
specialist_id :: binary(),
|
||||
location :: #location{},
|
||||
tags :: [binary()],
|
||||
capacity :: integer() | undefined,
|
||||
online_link :: binary() | undefined,
|
||||
capacity :: integer(),
|
||||
online_link :: binary(),
|
||||
status :: active | cancelled | completed,
|
||||
reason :: binary() | undefined,
|
||||
reason :: binary(),
|
||||
rating_avg :: float(),
|
||||
rating_count :: non_neg_integer(),
|
||||
attachments :: [binary()] | undefined,
|
||||
edit_history :: [map()] | undefined,
|
||||
attachments :: [binary()],
|
||||
edit_history :: [map()],
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
@@ -131,7 +131,7 @@
|
||||
master_id :: binary(),
|
||||
original_start :: calendar:datetime(),
|
||||
action :: cancel | reschedule,
|
||||
new_start :: calendar:datetime() | undefined
|
||||
new_start :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- Бронирования ------------------------------------
|
||||
@@ -140,9 +140,9 @@
|
||||
event_id :: binary(), % ссылка на конкретный экземпляр события
|
||||
user_id :: binary(),
|
||||
status :: pending | confirmed | cancelled,
|
||||
notes :: binary() | undefined,
|
||||
notes :: binary(),
|
||||
reminder_sent :: boolean(),
|
||||
confirmed_at :: calendar:datetime() | undefined,
|
||||
confirmed_at :: calendar:datetime(),
|
||||
created_at :: calendar:datetime(),
|
||||
updated_at :: calendar:datetime()
|
||||
}).
|
||||
@@ -156,7 +156,7 @@
|
||||
rating :: 1..5,
|
||||
comment :: binary(),
|
||||
status :: visible | hidden | deleted,
|
||||
reason :: binary() | undefined,
|
||||
reason :: binary(),
|
||||
likes :: non_neg_integer(),
|
||||
dislikes :: non_neg_integer(),
|
||||
created_at :: calendar:datetime(),
|
||||
@@ -172,15 +172,15 @@
|
||||
reason :: binary(),
|
||||
status :: pending | reviewed | dismissed,
|
||||
created_at :: calendar:datetime(),
|
||||
resolved_at :: calendar:datetime() | undefined,
|
||||
resolved_by :: binary() | undefined
|
||||
resolved_at :: calendar:datetime(),
|
||||
resolved_by :: binary()
|
||||
}).
|
||||
|
||||
-record(banned_word, {
|
||||
id :: binary(),
|
||||
word :: binary(),
|
||||
added_by :: binary() | undefined, % id администратора, добавившего слово
|
||||
added_at :: calendar:datetime() | undefined
|
||||
added_by :: binary(), % id администратора, добавившего слово
|
||||
added_at :: calendar:datetime()
|
||||
}).
|
||||
|
||||
%% ------------------- Баг-трекер --------------------------------------
|
||||
@@ -195,8 +195,8 @@
|
||||
first_seen :: calendar:datetime(),
|
||||
last_seen :: calendar:datetime(),
|
||||
status :: open | in_progress | resolved | closed,
|
||||
assigned_to :: binary() | undefined,
|
||||
resolution_note :: binary() | undefined
|
||||
assigned_to :: binary(),
|
||||
resolution_note :: binary()
|
||||
}).
|
||||
|
||||
%% ------------------- Подписки ----------------------------------------
|
||||
@@ -223,10 +223,10 @@
|
||||
entity_id :: binary(),
|
||||
timestamp :: calendar:datetime(),
|
||||
ip :: binary(),
|
||||
reason :: binary() | undefined
|
||||
reason :: binary()
|
||||
}).
|
||||
|
||||
%% ------------------- Уведомления (задача #12) ------------------------
|
||||
%% ------------------- Уведомления ------------------------
|
||||
-record(notification, {
|
||||
id :: binary(),
|
||||
user_id :: binary(),
|
||||
|
||||
@@ -1,24 +1,51 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Единый модуль аутентификации запросов.
|
||||
%%% Извлекает JWT из заголовка `Authorization: Bearer <token>`,
|
||||
%%% проверяет его и возвращает `{ok, UserId, Req}` или ошибку.
|
||||
%%% Используется модулем `handler_utils` и другими обработчиками.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_auth).
|
||||
|
||||
-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) ->
|
||||
io:format("[AUTH] Starting authentication...~n"),
|
||||
case cowboy_req:parse_header(<<"authorization">>, Req) of
|
||||
{bearer, Token} ->
|
||||
io:format("[AUTH] Bearer token found: ~s...~n", [binary_part(Token, 0, 30)]),
|
||||
case logic_auth:verify_jwt(Token) of
|
||||
{ok, UserId, _Role} ->
|
||||
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]),
|
||||
verify_token(Token, Req);
|
||||
_ ->
|
||||
io:format("[AUTH] No bearer token~n"),
|
||||
{error, 401, <<"Missing or invalid Authorization header">>, Req}
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Проверяет переданный JWT-токен.
|
||||
-spec verify_token(binary(), cowboy_req:req()) ->
|
||||
{ok, binary(), cowboy_req:req()} | {error, 401, binary(), cowboy_req:req()}.
|
||||
verify_token(Token, Req) ->
|
||||
case logic_auth:verify_jwt(Token) of
|
||||
{ok, UserId, _Role} ->
|
||||
{ok, UserId, Req};
|
||||
{error, expired} ->
|
||||
io:format("[AUTH] JWT expired~n"),
|
||||
{error, 401, <<"Token expired">>, Req};
|
||||
{error, _Reason} ->
|
||||
io:format("[AUTH] JWT invalid~n"),
|
||||
{error, 401, <<"Invalid token">>, Req}
|
||||
end.
|
||||
@@ -1,130 +1,196 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик конкретного бронирования (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить информацию о бронировании.
|
||||
%%% PUT – подтвердить или отклонить бронирование (владельцем).
|
||||
%%% DELETE – отменить бронирование (участником).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_booking_by_id).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
-include("records.hrl").
|
||||
|
||||
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
|
||||
<<"GET">> -> get_booking(Req);
|
||||
<<"PUT">> -> update_booking(Req);
|
||||
<<"GET">> -> get_booking(Req);
|
||||
<<"PUT">> -> update_booking(Req);
|
||||
<<"DELETE">> -> cancel_booking(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
BookingId = cowboy_req:binding(id, Req1),
|
||||
case logic_booking:get_booking(UserId, BookingId) of
|
||||
{ok, Booking} ->
|
||||
Response = booking_to_json(Booking),
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, booking_to_json(Booking));
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Booking not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Booking not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% PUT /v1/bookings/:id - подтверждение/отклонение бронирования (владельцем)
|
||||
%% @doc Подтвердить или отклонить бронирование (владельцем).
|
||||
-spec update_booking(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
update_booking(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
BookingId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Decoded when is_map(Decoded) ->
|
||||
case maps:get(<<"action">>, Decoded, undefined) of
|
||||
<<"confirm">> ->
|
||||
case logic_booking:confirm_booking(UserId, BookingId, confirm) 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;
|
||||
<<"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'">>)
|
||||
#{<<"action">> := Action} when Action =:= <<"confirm">>; Action =:= <<"decline">> ->
|
||||
ActionAtom = binary_to_existing_atom(Action, utf8),
|
||||
case logic_booking:confirm_booking(UserId, BookingId, ActionAtom) of
|
||||
{ok, Booking} ->
|
||||
handler_utils:send_json(Req2, 200, booking_to_json(Booking));
|
||||
{error, access_denied} ->
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req2, 404, <<"Booking not found">>);
|
||||
{error, _} ->
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Missing or invalid 'action' field. Use 'confirm' or 'decline'">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% DELETE /v1/bookings/:id - отмена бронирования (участником)
|
||||
%% @doc Отменить бронирование (участником).
|
||||
-spec cancel_booking(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
cancel_booking(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
BookingId = cowboy_req:binding(id, Req1),
|
||||
case logic_booking:cancel_booking(UserId, BookingId) of
|
||||
{ok, Booking} ->
|
||||
Response = booking_to_json(Booking),
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, booking_to_json(Booking));
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Booking not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Booking not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
%% @private Формирует JSON-представление записи #booking{}.
|
||||
%% Учитывает все поля из records.hrl.
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
#{
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
|
||||
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, []}.
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
notes => Booking#booking.notes,
|
||||
reminder_sent => Booking#booking.reminder_sent,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> handler_utils:datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
@@ -1,89 +1,175 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик маршрута `/v1/events/:id/bookings`.
|
||||
%%%
|
||||
%%% POST – Создание бронирования (запись на событие).
|
||||
%%% GET – Получение списка бронирований события (для владельца календаря).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_bookings).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> create_booking(Req);
|
||||
<<"GET">> -> list_bookings(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_bookings(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
case logic_booking:create_booking(UserId, EventId) of
|
||||
{ok, Booking} ->
|
||||
Response = booking_to_json(Booking),
|
||||
send_json(Req1, 201, Response);
|
||||
handler_utils:send_json(Req1, 201, booking_to_json(Booking));
|
||||
{error, already_booked} ->
|
||||
send_error(Req1, 409, <<"Already booked">>);
|
||||
handler_utils:send_error(Req1, 409, <<"Already booked">>);
|
||||
{error, event_full} ->
|
||||
send_error(Req1, 400, <<"Event is full">>);
|
||||
handler_utils:send_error(Req1, 400, <<"Event is full">>);
|
||||
{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} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
case logic_booking:list_event_bookings(UserId, EventId) of
|
||||
{ok, Bookings} ->
|
||||
Response = [booking_to_json(B) || B <- Bookings],
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Формирует JSON-представление записи #booking{}.
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
#{
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
|
||||
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, []}.
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
notes => Booking#booking.notes,
|
||||
reminder_sent => Booking#booking.reminder_sent,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> handler_utils:datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
@@ -1,42 +1,159 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик конкретного календаря (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить информацию о календаре.
|
||||
%%% PUT – обновить календарь (владельцем).
|
||||
%%% DELETE – удалить календарь (владельцем).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_by_id).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_calendar(Req);
|
||||
<<"PUT">> -> update_calendar(Req);
|
||||
<<"GET">> -> get_calendar(Req);
|
||||
<<"PUT">> -> update_calendar(Req);
|
||||
<<"DELETE">> -> delete_calendar(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar:get_calendar(UserId, CalendarId) of
|
||||
{ok, Calendar} ->
|
||||
Response = calendar_to_json(Calendar),
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, handler_utils:calendar_to_json(Calendar));
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Calendar not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% PUT /v1/calendars/:id - обновление календаря
|
||||
%% @doc PUT /v1/calendars/:id — обновление календаря.
|
||||
-spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
update_calendar(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
@@ -45,71 +162,39 @@ update_calendar(Req) ->
|
||||
Updates = maps:to_list(UpdatesMap),
|
||||
case logic_calendar:update_calendar(UserId, CalendarId, Updates) of
|
||||
{ok, Calendar} ->
|
||||
Response = calendar_to_json(Calendar),
|
||||
send_json(Req2, 200, Response);
|
||||
handler_utils:send_json(Req2, 200, handler_utils:calendar_to_json(Calendar));
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Calendar not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% DELETE /v1/calendars/:id - удаление календаря
|
||||
%% @doc DELETE /v1/calendars/:id — удаление календаря.
|
||||
-spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
delete_calendar(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(id, Req1),
|
||||
case logic_calendar:delete_calendar(UserId, CalendarId) of
|
||||
{ok, _} ->
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Calendar not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
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, []}.
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -1,110 +1,144 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик календарного представления (HTML-календарь).
|
||||
%%%
|
||||
%%% GET – возвращает HTML-страницу с календарём на указанный месяц.
|
||||
%%% Требует параметр `month` в формате YYYY-MM.
|
||||
%%% Доступно только владельцу календаря.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendar_view).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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),
|
||||
case verify_token(Req) of
|
||||
{ok, UserId} ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case is_owner(UserId, CalendarId) of
|
||||
true ->
|
||||
process_view(Req, CalendarId, Opts);
|
||||
false ->
|
||||
cowboy_req:reply(403,
|
||||
#{<<"content-type">> => <<"application/json">>},
|
||||
jsx:encode(#{error => <<"Access denied">>}),
|
||||
Req),
|
||||
{ok, Req, Opts}
|
||||
true -> process_view(Req1, CalendarId);
|
||||
false -> handler_utils:send_error(Req1, 403, <<"Access denied">>)
|
||||
end;
|
||||
{error, _Reason} ->
|
||||
cowboy_req:reply(401,
|
||||
#{<<"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}
|
||||
{error, _Code, _Msg, Req1} ->
|
||||
handler_utils:send_error(Req1, 401, <<"Unauthorized">>)
|
||||
end.
|
||||
|
||||
%% @private Проверяет, является ли пользователь владельцем календаря.
|
||||
-spec is_owner(binary(), binary()) -> boolean().
|
||||
is_owner(UserId, CalendarId) ->
|
||||
case mnesia:dirty_read({calendar, CalendarId}) of
|
||||
[#calendar{owner_id = UserId}] -> true;
|
||||
_ -> false
|
||||
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),
|
||||
MonthBin = case lists:keyfind(<<"month">>, 1, Qs) of
|
||||
{<<"month">>, Value} -> Value;
|
||||
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 lists:keyfind(<<"month">>, 1, Qs) of
|
||||
{<<"month">>, MonthBin} ->
|
||||
case binary:split(MonthBin, <<"-">>) of
|
||||
[YearStr, MonthStr] ->
|
||||
Year = binary_to_integer(YearStr),
|
||||
Year = binary_to_integer(YearStr),
|
||||
Month = binary_to_integer(MonthStr),
|
||||
Events = fetch_events(CalendarId, Year, Month),
|
||||
Html = calendar_html_renderer:render_month(Year, Month, Events),
|
||||
Req2 = cowboy_req:reply(200,
|
||||
#{<<"content-type">> => <<"text/html">>,
|
||||
<<"cache-control">> => <<"public, max-age=86400">>},
|
||||
Html,
|
||||
Req),
|
||||
{ok, Req2, Opts};
|
||||
Html = calendar_html_renderer:render_month(Year, Month, Events),
|
||||
Headers = #{
|
||||
<<"content-type">> => <<"text/html">>,
|
||||
<<"cache-control">> => <<"public, max-age=86400">>
|
||||
},
|
||||
cowboy_req:reply(200, Headers, Html, Req),
|
||||
{ok, Req, undefined};
|
||||
_ ->
|
||||
cowboy_req:reply(400,
|
||||
#{<<"content-type">> => <<"application/json">>},
|
||||
jsx:encode(#{error => <<"Invalid 'month' format. Use YYYY-MM">>}),
|
||||
Req),
|
||||
{ok, Req, Opts}
|
||||
end
|
||||
handler_utils:send_error(Req, 400, <<"Invalid 'month' format. Use YYYY-MM">>)
|
||||
end;
|
||||
false ->
|
||||
handler_utils:send_error(Req, 400, <<"Missing 'month' parameter">>)
|
||||
end.
|
||||
|
||||
%% @private Извлекает события для указанного месяца календаря.
|
||||
-spec fetch_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
fetch_events(CalendarId, Year, Month) ->
|
||||
IsHot = is_hot(Year, Month),
|
||||
if IsHot ->
|
||||
fetch_hot_events(CalendarId, Year, Month);
|
||||
true ->
|
||||
fetch_archive_events(CalendarId, Year, Month)
|
||||
case is_hot(Year, Month) of
|
||||
true -> fetch_hot_events(CalendarId, Year, Month);
|
||||
false -> fetch_archive_events(CalendarId, Year, Month)
|
||||
end.
|
||||
|
||||
%% @private Определяет, является ли месяц "горячим" (в пределах 30 дней от текущей даты).
|
||||
-spec is_hot(integer(), integer()) -> boolean().
|
||||
is_hot(Year, Month) ->
|
||||
Current = calendar:local_time(),
|
||||
Target = {{Year, Month, 1}, {0,0,0}},
|
||||
calendar:datetime_to_gregorian_seconds(Current)
|
||||
- calendar:datetime_to_gregorian_seconds(Target) < 30*86400.
|
||||
Target = {{Year, Month, 1}, {0, 0, 0}},
|
||||
calendar:datetime_to_gregorian_seconds(Current) -
|
||||
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) ->
|
||||
Start = {{Year, Month, 1}, {0,0,0}},
|
||||
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23,59,59}},
|
||||
mnesia:dirty_select(event,
|
||||
[{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'},
|
||||
[{'>=','$1', {const, Start}}, {'=<','$1', {const, End}}],
|
||||
['$_']}]).
|
||||
Start = {{Year, Month, 1}, {0, 0, 0}},
|
||||
End = {{Year, Month, calendar:last_day_of_the_month(Year, Month)}, {23, 59, 59}},
|
||||
mnesia:dirty_select(event, [
|
||||
{#event{calendar_id = CalendarId, start_time = '$1', _ = '_'},
|
||||
[{'>=', '$1', {const, Start}}, {'=<', '$1', {const, End}}],
|
||||
['$_']}
|
||||
]).
|
||||
|
||||
%% @private Извлекает архивные события через RPC на архивный узел.
|
||||
-spec fetch_archive_events(binary(), integer(), integer()) -> list(#event{}).
|
||||
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
|
||||
{ok, Node} ->
|
||||
rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month]);
|
||||
{error, _} ->
|
||||
[]
|
||||
{ok, Node} -> rpc:call(Node, archive_fetcher, fetch, [CalendarId, Year, Month]);
|
||||
{error, _} -> []
|
||||
end.
|
||||
@@ -1,122 +1,194 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик маршрута `/v1/calendars`.
|
||||
%%%
|
||||
%%% POST – создание нового календаря (требуется подписка для commercial).
|
||||
%%% GET – получение списка календарей пользователя.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_calendars).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
-include("records.hrl").
|
||||
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> create_calendar(Req);
|
||||
<<"GET">> -> list_calendars(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
%%% cowboy_handler callback
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req0, _State) ->
|
||||
case cowboy_req:method(Req0) of
|
||||
<<"POST">> -> create_calendar(Req0);
|
||||
<<"GET">> -> list_calendars(Req0);
|
||||
_ -> handler_utils:send_error(Req0, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Decoded when is_map(Decoded) ->
|
||||
case Decoded of
|
||||
#{<<"title">> := Title} ->
|
||||
Description = maps:get(<<"description">>, Decoded, <<"">>),
|
||||
Description = maps:get(<<"description">>, Decoded, <<"">>),
|
||||
Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)),
|
||||
Tags = maps:get(<<"tags">>, Decoded, []),
|
||||
Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)),
|
||||
|
||||
% Проверяем подписку для commercial календарей ДО создания
|
||||
Tags = maps:get(<<"tags">>, Decoded, []),
|
||||
Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)),
|
||||
case Type of
|
||||
commercial ->
|
||||
case logic_subscription:can_create_commercial_calendar(UserId) of
|
||||
true -> ok;
|
||||
true -> ok;
|
||||
false ->
|
||||
send_error(Req2, 402, <<"Subscription required for commercial calendar">>),
|
||||
handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>),
|
||||
throw(stop)
|
||||
end;
|
||||
personal -> ok
|
||||
end,
|
||||
|
||||
case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of
|
||||
{ok, Calendar} ->
|
||||
% Обновляем теги и тип
|
||||
Updates = [{tags, Tags}, {type, Type}],
|
||||
core_calendar:update(Calendar#calendar.id, Updates),
|
||||
{ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id),
|
||||
Response = calendar_to_json(Updated),
|
||||
send_json(Req2, 201, Response);
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, user_inactive} ->
|
||||
send_error(Req2, 403, <<"User account is not active">>);
|
||||
handler_utils:send_error(Req2, 403, <<"User account is not active">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing required field: title">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Missing required field: title">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
throw:stop -> ok; % Уже отправили ошибку
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
throw:stop -> ok;
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
parse_confirmation(<<"auto">>) -> auto;
|
||||
parse_confirmation(<<"manual">>) -> manual;
|
||||
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 - список календарей
|
||||
%% @doc GET /v1/calendars — список календарей пользователя.
|
||||
-spec list_calendars(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_calendars(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_calendar:list_calendars(UserId) of
|
||||
{ok, Calendars} ->
|
||||
Response = [calendar_to_json(C) || C <- Calendars],
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
-spec calendar_to_json(#calendar{}) -> map().
|
||||
calendar_to_json(Calendar) ->
|
||||
#{
|
||||
id => Calendar#calendar.id,
|
||||
owner_id => Calendar#calendar.owner_id,
|
||||
title => Calendar#calendar.title,
|
||||
description => Calendar#calendar.description,
|
||||
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
|
||||
}.
|
||||
Base = handler_utils:calendar_to_json(Calendar),
|
||||
Base#{confirmation => confirmation_to_json(Calendar#calendar.confirmation)}.
|
||||
|
||||
confirmation_to_json(auto) -> <<"auto">>;
|
||||
confirmation_to_json(manual) -> <<"manual">>;
|
||||
confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}.
|
||||
-spec confirmation_to_json(auto | manual | {timeout, integer()}) -> binary() | map().
|
||||
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, []}.
|
||||
-spec parse_confirmation(binary() | map()) -> auto | manual | {timeout, integer()}.
|
||||
parse_confirmation(<<"auto">>) -> auto;
|
||||
parse_confirmation(<<"manual">>) -> manual;
|
||||
parse_confirmation(#{<<"timeout">> := N}) when is_integer(N), N > 0 -> {timeout, N};
|
||||
parse_confirmation(_) -> manual.
|
||||
|
||||
send_error(Req, Status, Message) ->
|
||||
Body = jsx:encode(#{error => Message}),
|
||||
cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Body, []}.
|
||||
-spec parse_type(binary()) -> personal | commercial.
|
||||
parse_type(<<"personal">>) -> personal;
|
||||
parse_type(<<"commercial">>) -> commercial;
|
||||
parse_type(_) -> personal.
|
||||
@@ -1,42 +1,175 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик конкретного события (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить информацию о событии.
|
||||
%%% PUT – обновить событие.
|
||||
%%% DELETE – удалить событие.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_event_by_id).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_event(Req);
|
||||
<<"PUT">> -> update_event(Req);
|
||||
<<"GET">> -> get_event(Req);
|
||||
<<"PUT">> -> update_event(Req);
|
||||
<<"DELETE">> -> delete_event(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% GET /v1/events/:id - получение события
|
||||
%% @doc GET /v1/events/:id — получение события.
|
||||
-spec get_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_event(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
case logic_event:get_event(UserId, EventId) of
|
||||
{ok, Event} ->
|
||||
Response = event_to_json(Event),
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, handler_utils:event_to_json(Event));
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% PUT /v1/events/:id - обновление события
|
||||
%% @doc PUT /v1/events/:id — обновление события.
|
||||
-spec update_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
update_event(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
@@ -46,136 +179,83 @@ update_event(Req) ->
|
||||
UpdatesWithTypes = convert_fields(Updates),
|
||||
case logic_event:update_event(UserId, EventId, UpdatesWithTypes) of
|
||||
{ok, Event} ->
|
||||
Response = event_to_json(Event),
|
||||
send_json(Req2, 200, Response);
|
||||
handler_utils:send_json(Req2, 200, handler_utils:event_to_json(Event));
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Event not found">>);
|
||||
{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, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% DELETE /v1/events/:id - удаление события
|
||||
%% @doc DELETE /v1/events/:id — удаление события.
|
||||
-spec delete_event(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
delete_event(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
case logic_event:delete_event(UserId, EventId) of
|
||||
{ok, _} ->
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
%%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Преобразует поля из бинарных ключей в атомы и значения в правильные типы.
|
||||
-spec convert_fields([{binary(), term()}]) -> [{atom(), term()}].
|
||||
convert_fields(Updates) ->
|
||||
lists:map(fun
|
||||
({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).
|
||||
lists:map(fun convert_field/1, Updates).
|
||||
|
||||
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}}}
|
||||
-spec convert_field({binary(), term()}) -> {atom(), term()}.
|
||||
convert_field({<<"title">>, Val}) -> {title, Val};
|
||||
convert_field({<<"description">>, Val}) -> {description, Val};
|
||||
convert_field({<<"event_type">>, Val}) -> {event_type, Val};
|
||||
convert_field({<<"start_time">>, Val}) ->
|
||||
case handler_utils:parse_datetime(Val) of
|
||||
{ok, Dt} -> {start_time, Dt};
|
||||
_ -> {start_time, Val}
|
||||
end;
|
||||
convert_field({<<"duration">>, Val}) -> {duration, Val};
|
||||
convert_field({<<"recurrence">>, Val}) ->
|
||||
RuleJson = jsx:encode(Val),
|
||||
{recurrence_rule, RuleJson};
|
||||
convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val};
|
||||
convert_field({<<"location">>, Val}) when is_map(Val) ->
|
||||
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
|
||||
_:_ -> {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)
|
||||
}.
|
||||
|
||||
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, []}.
|
||||
error:badarg -> {status, Val}
|
||||
end;
|
||||
convert_field(Other) -> Other.
|
||||
@@ -1,143 +1,223 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик вхождений повторяющегося события (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить список вхождений события в заданном диапазоне.
|
||||
%%% DELETE – отменить конкретное вхождение.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_event_occurrences).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_occurrences(Req);
|
||||
<<"GET">> -> get_occurrences(Req);
|
||||
<<"DELETE">> -> cancel_occurrence(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
Qs = cowboy_req:parse_qs(Req1),
|
||||
|
||||
% Параметры диапазона
|
||||
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
|
||||
{undefined, _} ->
|
||||
send_error(Req1, 400, <<"Missing 'from' parameter">>);
|
||||
handler_utils:send_error(Req1, 400, <<"Missing 'from' parameter">>);
|
||||
{_, undefined} ->
|
||||
send_error(Req1, 400, <<"Missing 'to' parameter">>);
|
||||
handler_utils:send_error(Req1, 400, <<"Missing 'to' parameter">>);
|
||||
{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}} ->
|
||||
case logic_event:get_occurrences(UserId, EventId, ToDt) of
|
||||
{ok, Occurrences} ->
|
||||
% Фильтруем по from
|
||||
Filtered = filter_from(Occurrences, FromDt),
|
||||
Response = occurrences_to_json(Filtered),
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, not_recurring} ->
|
||||
send_error(Req1, 400, <<"Event is not recurring">>);
|
||||
handler_utils:send_error(Req1, 400, <<"Event is not recurring">>);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
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;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
EventId = cowboy_req:binding(id, Req1),
|
||||
StartTimeStr = cowboy_req:binding(start_time, Req1),
|
||||
|
||||
case parse_datetime(StartTimeStr) of
|
||||
case handler_utils:parse_datetime(StartTimeStr) of
|
||||
{ok, StartTime} ->
|
||||
case logic_event:cancel_occurrence(UserId, EventId, StartTime) of
|
||||
{ok, cancelled} ->
|
||||
send_json(Req1, 200, #{status => <<"cancelled">>});
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"cancelled">>});
|
||||
{error, not_recurring} ->
|
||||
send_error(Req1, 400, <<"Event is not recurring">>);
|
||||
handler_utils:send_error(Req1, 400, <<"Event is not recurring">>);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Event not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Event not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, _} ->
|
||||
send_error(Req1, 400, <<"Invalid start_time format">>)
|
||||
handler_utils:send_error(Req1, 400, <<"Invalid start_time format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
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) ->
|
||||
lists:filter(fun
|
||||
({virtual, Occ}) -> Occ >= From;
|
||||
({materialized, Event}) -> Event#event.start_time >= From
|
||||
end, Occurrences).
|
||||
lists:filter(
|
||||
fun({virtual, Occ}) ->
|
||||
Occ >= From;
|
||||
({materialized, Event}) ->
|
||||
Event#event.start_time >= From
|
||||
end, Occurrences).
|
||||
|
||||
%% @private Преобразует список вхождений в JSON-представление.
|
||||
-spec occurrences_to_json(list()) -> list().
|
||||
occurrences_to_json(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}) ->
|
||||
#{
|
||||
start_time => datetime_to_iso8601(Occ),
|
||||
start_time => handler_utils:datetime_to_iso8601(Occ),
|
||||
is_virtual => true
|
||||
};
|
||||
occurrence_to_json({materialized, Event}) ->
|
||||
#{
|
||||
id => Event#event.id,
|
||||
start_time => datetime_to_iso8601(Event#event.start_time),
|
||||
duration => Event#event.duration,
|
||||
id => Event#event.id,
|
||||
start_time => handler_utils:datetime_to_iso8601(Event#event.start_time),
|
||||
duration => Event#event.duration,
|
||||
specialist_id => Event#event.specialist_id,
|
||||
is_virtual => false,
|
||||
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, []}.
|
||||
is_virtual => false,
|
||||
status => Event#event.status
|
||||
}.
|
||||
@@ -1,148 +1,270 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик маршрута `/v1/calendars/:calendar_id/events`.
|
||||
%%%
|
||||
%%% POST – создание нового события (одиночного или повторяющегося).
|
||||
%%% GET – получение списка событий календаря с возможностью фильтрации
|
||||
%%% по диапазону дат и разворачиванием повторяющихся событий.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_events).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> create_event(Req);
|
||||
<<"GET">> -> list_events(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_events(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Decoded when is_map(Decoded) ->
|
||||
case Decoded of
|
||||
#{<<"title">> := Title,
|
||||
<<"start_time">> := StartTimeStr,
|
||||
<<"duration">> := Duration} ->
|
||||
case parse_datetime(StartTimeStr) of
|
||||
#{<<"title">> := Title, <<"start_time">> := StartTimeStr, <<"duration">> := Duration} ->
|
||||
case handler_utils:parse_datetime(StartTimeStr) of
|
||||
{ok, StartTime} ->
|
||||
% Парсим location если есть
|
||||
Location = parse_location(maps:get(<<"location">>, Decoded, undefined)),
|
||||
|
||||
% Проверяем, есть ли правило повторения
|
||||
case maps:get(<<"recurrence">>, Decoded, undefined) of
|
||||
undefined ->
|
||||
% Одиночное событие
|
||||
case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of
|
||||
{ok, Event} ->
|
||||
% Обновляем location и capacity если нужно
|
||||
update_event_fields(Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = event_to_json(UpdatedEvent),
|
||||
send_json(Req2, 201, Response);
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Calendar not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{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, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
RRule ->
|
||||
% Повторяющееся событие
|
||||
case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of
|
||||
{ok, Event} ->
|
||||
update_event_fields(Event#event.id, Location, Decoded),
|
||||
{ok, UpdatedEvent} = core_event:get_by_id(Event#event.id),
|
||||
Response = event_to_json(UpdatedEvent),
|
||||
send_json(Req2, 201, Response);
|
||||
Response = handler_utils:event_to_json(UpdatedEvent),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, invalid_rrule} ->
|
||||
send_error(Req2, 400, <<"Invalid recurrence rule">>);
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid recurrence rule">>);
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Calendar not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Calendar not found">>);
|
||||
{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, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end
|
||||
end;
|
||||
{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;
|
||||
_ ->
|
||||
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;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
CalendarId = cowboy_req:binding(calendar_id, Req1),
|
||||
Qs = cowboy_req:parse_qs(Req1),
|
||||
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
|
||||
{ok, Events} ->
|
||||
Response = case {From, To} of
|
||||
{undefined, undefined} ->
|
||||
[event_to_json(E) || E <- Events];
|
||||
[handler_utils:event_to_json(E) || E <- Events];
|
||||
{FromStr, ToStr} ->
|
||||
FromDt = parse_datetime_binary(FromStr),
|
||||
ToDt = parse_datetime_binary(ToStr),
|
||||
ToDt = parse_datetime_binary(ToStr),
|
||||
expand_recurring_events(UserId, Events, FromDt, ToDt)
|
||||
end,
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Calendar not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
%%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
|
||||
update_event_fields(EventId, Location, Decoded) ->
|
||||
Updates = [],
|
||||
Updates1 = case Location of
|
||||
undefined -> Updates;
|
||||
_ -> [{location, Location} | Updates]
|
||||
end,
|
||||
Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of
|
||||
undefined -> Updates1;
|
||||
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.
|
||||
Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end,
|
||||
Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of undefined -> Updates1; 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(LocationMap) when is_map(LocationMap) ->
|
||||
@@ -159,116 +281,40 @@ expand_recurring_events(UserId, Events, From, To) ->
|
||||
case Event#event.event_type of
|
||||
single ->
|
||||
case is_in_range(Event#event.start_time, From, To) of
|
||||
true -> [event_to_json(Event)];
|
||||
true -> [handler_utils:event_to_json(Event)];
|
||||
false -> []
|
||||
end;
|
||||
recurring ->
|
||||
case logic_event:get_occurrences(UserId, Event#event.id, To) of
|
||||
{ok, Occurrences} ->
|
||||
lists:filtermap(fun
|
||||
({virtual, Occ}) ->
|
||||
case is_in_range(Occ, From, To) of
|
||||
true -> {true, occurrence_to_json(Event, Occ)};
|
||||
false -> false
|
||||
end;
|
||||
({materialized, Instance}) ->
|
||||
case is_in_range(Instance#event.start_time, From, To) of
|
||||
true -> {true, event_to_json(Instance)};
|
||||
false -> false
|
||||
end
|
||||
end, Occurrences);
|
||||
_ ->
|
||||
[]
|
||||
lists:filtermap(
|
||||
fun({virtual, Occ}) ->
|
||||
case is_in_range(Occ, From, To) of
|
||||
true -> {true, occurrence_to_json(Event, Occ)};
|
||||
false -> false
|
||||
end;
|
||||
({materialized, Instance}) ->
|
||||
case is_in_range(Instance#event.start_time, From, To) of
|
||||
true -> {true, handler_utils:event_to_json(Instance)};
|
||||
false -> false
|
||||
end
|
||||
end, Occurrences);
|
||||
_ -> []
|
||||
end
|
||||
end
|
||||
end, Events).
|
||||
|
||||
is_in_range(Time, From, To) ->
|
||||
Time >= From andalso Time =< To.
|
||||
is_in_range(Time, From, To) -> Time >= From andalso Time =< To.
|
||||
|
||||
parse_datetime_binary(Str) ->
|
||||
{ok, Dt} = parse_datetime(Str),
|
||||
{ok, Dt} = handler_utils:parse_datetime(Str),
|
||||
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) ->
|
||||
#{
|
||||
master_id => Master#event.id,
|
||||
start_time => datetime_to_iso8601(Occurrence),
|
||||
duration => Master#event.duration,
|
||||
title => Master#event.title,
|
||||
master_id => Master#event.id,
|
||||
start_time => handler_utils:datetime_to_iso8601(Occurrence),
|
||||
duration => Master#event.duration,
|
||||
title => Master#event.title,
|
||||
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).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
%%% 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/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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> ->
|
||||
Body = jsx:encode(#{status => <<"ok">>}),
|
||||
Req1 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req1, []};
|
||||
handler_utils:send_json(Req, 200, #{status => <<"ok">>});
|
||||
_ ->
|
||||
Body = jsx:encode(#{error => <<"Method not allowed">>}),
|
||||
Req1 = cowboy_req:reply(405, #{<<"content-type">> => <<"application/json">>}, Body, Req),
|
||||
{ok, Req1, []}
|
||||
handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
@@ -1,69 +1,104 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик входа пользователя (клиентский API).
|
||||
%%% POST – аутентифицирует пользователя по email и паролю,
|
||||
%%% возвращает JWT токен и refresh токен.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_login).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req0, State) ->
|
||||
handle(Req0, State).
|
||||
%%% 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/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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> ->
|
||||
case cowboy_req:has_body(Req) of
|
||||
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">>)
|
||||
<<"POST">> -> login(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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, []}.
|
||||
%% @doc POST /v1/login — аутентификация пользователя.
|
||||
-spec login(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
login(Req) ->
|
||||
case cowboy_req:has_body(Req) of
|
||||
true ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req),
|
||||
case Body of
|
||||
<<>> ->
|
||||
handler_utils: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} ->
|
||||
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).
|
||||
-include("records.hrl").
|
||||
-export([init/2]).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
init(Req0, _Opts) ->
|
||||
case cowboy_req:method(Req0) of
|
||||
<<"POST">> ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req0),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"refresh_token">> := RefreshToken} ->
|
||||
case find_and_refresh(RefreshToken) of
|
||||
{ok, NewTokenPair} ->
|
||||
Resp = jsx:encode(NewTokenPair),
|
||||
cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Resp, Req1);
|
||||
{error, Reason} ->
|
||||
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">>)
|
||||
-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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> refresh(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case core_session:validate(RefreshToken) of
|
||||
{ok, UserId, User} ->
|
||||
@@ -45,31 +91,23 @@ find_and_refresh(RefreshToken) ->
|
||||
{error, <<"Refresh token expired">>}
|
||||
end.
|
||||
|
||||
%% @private Обновляет токен для обычного пользователя.
|
||||
-spec user_refresh(binary(), #user{}, binary()) -> {ok, map()}.
|
||||
user_refresh(UserId, User, OldToken) ->
|
||||
% Удаляем старый refresh-токен
|
||||
core_session:delete(OldToken),
|
||||
% Генерируем новый access-токен и refresh-токен
|
||||
Role = atom_to_binary(User#user.role, utf8),
|
||||
NewToken = eventhub_auth:generate_user_token(UserId, Role),
|
||||
{NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(UserId),
|
||||
% Сохраняем новый refresh-токен в таблице session
|
||||
core_session:create(UserId, NewRefreshToken),
|
||||
{ok, #{token => NewToken, refresh_token => NewRefreshToken}}.
|
||||
|
||||
%% @private Обновляет токен для администратора.
|
||||
-spec admin_refresh(binary(), binary()) -> {ok, map()}.
|
||||
admin_refresh(AdminId, OldToken) ->
|
||||
% Удаляем старый refresh-токен
|
||||
core_admin_session:delete(OldToken),
|
||||
% Получаем роль админа
|
||||
{ok, Admin} = core_admin:get_by_id(AdminId),
|
||||
Role = atom_to_binary(Admin#admin.role, utf8),
|
||||
% Генерируем новый access-токен и refresh-токен
|
||||
NewToken = eventhub_auth:generate_admin_token(AdminId, Role),
|
||||
{NewRefreshToken, _ExpiresAt} = eventhub_auth:generate_refresh_token(AdminId),
|
||||
% Сохраняем новый refresh-токен в таблице admin_session
|
||||
core_admin_session:create(AdminId, 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, []}.
|
||||
{ok, #{token => NewToken, refresh_token => NewRefreshToken}}.
|
||||
@@ -1,65 +1,104 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик регистрации пользователя (клиентский API).
|
||||
%%% POST – создаёт нового пользователя, возвращает JWT токен.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_register).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> ->
|
||||
case cowboy_req:has_body(Req) of
|
||||
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">>)
|
||||
<<"POST">> -> register(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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, []}.
|
||||
%% @doc POST /v1/register — регистрация нового пользователя.
|
||||
-spec register(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
register(Req) ->
|
||||
case cowboy_req:has_body(Req) of
|
||||
true ->
|
||||
{ok, Body, Req1} = cowboy_req:read_body(Req),
|
||||
case Body of
|
||||
<<>> ->
|
||||
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).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> create_report(Req);
|
||||
<<"GET">> -> list_reports(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_reports(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% @doc POST /v1/reports — создание жалобы.
|
||||
-spec create_report(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
create_report(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
Decoded = jsx:decode(Body, [return_maps]),
|
||||
case Decoded of
|
||||
#{<<"target_type">> := TargetTypeBin,
|
||||
<<"target_id">> := TargetId,
|
||||
<<"reason">> := Reason} ->
|
||||
#{<<"target_type">> := TargetTypeBin, <<"target_id">> := TargetId, <<"reason">> := Reason} ->
|
||||
TargetType = parse_target_type(TargetTypeBin),
|
||||
case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of
|
||||
{ok, Report} ->
|
||||
Response = report_to_json(Report),
|
||||
send_json(Req2, 201, Response);
|
||||
Response = handler_utils:report_to_json(Report),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, target_not_found} ->
|
||||
send_error(Req2, 404, <<"Target not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Target not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing required fields">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Missing required fields">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% @doc GET /v1/reports — список жалоб (для администратора/модератора).
|
||||
-spec list_reports(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_reports(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_admin(Req) of
|
||||
{ok, AdminId, 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, _} ->
|
||||
case logic_moderation:get_reports(AdminId) of
|
||||
{ok, Reports} ->
|
||||
Response = [report_to_json(R) || R <- Reports],
|
||||
send_json(Req1, 200, Response);
|
||||
Response = [handler_utils:report_to_json(R) || R <- Reports],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Admin access required">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{TargetTypeBin, TargetId} ->
|
||||
TargetType = parse_target_type(TargetTypeBin),
|
||||
case logic_moderation:get_reports_by_target(AdminId, TargetType, TargetId) of
|
||||
{ok, Reports} ->
|
||||
Response = [report_to_json(R) || R <- Reports],
|
||||
send_json(Req1, 200, Response);
|
||||
Response = [handler_utils:report_to_json(R) || R <- Reports],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Admin access required">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Admin access required">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
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(_) -> 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, []}.
|
||||
parse_target_type(<<"review">>) -> review;
|
||||
parse_target_type(_) -> undefined.
|
||||
@@ -1,42 +1,149 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик конкретного отзыва (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить отзыв по ID.
|
||||
%%% PUT – обновить отзыв (владельцем).
|
||||
%%% DELETE – удалить отзыв (владельцем).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_review_by_id).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_review(Req);
|
||||
<<"PUT">> -> update_review(Req);
|
||||
<<"GET">> -> get_review(Req);
|
||||
<<"PUT">> -> update_review(Req);
|
||||
<<"DELETE">> -> delete_review(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% GET /v1/reviews/:id - получение отзыва
|
||||
%% @doc GET /v1/reviews/:id — получение отзыва.
|
||||
-spec get_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_review(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
ReviewId = cowboy_req:binding(id, Req1),
|
||||
case logic_review:get_review(UserId, ReviewId) of
|
||||
{ok, Review} ->
|
||||
Response = review_to_json(Review),
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review));
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Review not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% PUT /v1/reviews/:id - обновление отзыва
|
||||
%% @doc PUT /v1/reviews/:id — обновление отзыва.
|
||||
-spec update_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
update_review(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
ReviewId = cowboy_req:binding(id, Req1),
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
@@ -45,74 +152,44 @@ update_review(Req) ->
|
||||
Updates = maps:to_list(UpdatesMap),
|
||||
case logic_review:update_review(UserId, ReviewId, Updates) of
|
||||
{ok, _} ->
|
||||
% Получаем обновлённый отзыв из базы
|
||||
case core_review:get_by_id(ReviewId) of
|
||||
{ok, Updated} ->
|
||||
Response = review_to_json(Updated),
|
||||
send_json(Req2, 200, Response);
|
||||
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated));
|
||||
_ ->
|
||||
send_error(Req2, 500, <<"Failed to retrieve updated review">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Failed to retrieve updated review">>)
|
||||
end;
|
||||
{error, access_denied} ->
|
||||
send_error(Req2, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req2, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req2, 404, <<"Review not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% DELETE /v1/reviews/:id - удаление отзыва
|
||||
%% @doc DELETE /v1/reviews/:id — удаление отзыва.
|
||||
-spec delete_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
delete_review(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
ReviewId = cowboy_req:binding(id, Req1),
|
||||
case logic_review:delete_review(UserId, ReviewId) of
|
||||
{ok, deleted} ->
|
||||
send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
handler_utils:send_json(Req1, 200, #{status => <<"deleted">>});
|
||||
{error, access_denied} ->
|
||||
send_error(Req1, 403, <<"Access denied">>);
|
||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||
{error, not_found} ->
|
||||
send_error(Req1, 404, <<"Review not found">>);
|
||||
handler_utils:send_error(Req1, 404, <<"Review not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
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, []}.
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -1,109 +1,190 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик отзывов (клиентский API).
|
||||
%%%
|
||||
%%% POST – создание нового отзыва.
|
||||
%%% GET – получение списка отзывов для указанной цели.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_reviews).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"POST">> -> create_review(Req);
|
||||
<<"GET">> -> list_reviews(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_reviews(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% POST /v1/reviews - создание отзыва
|
||||
%% @doc POST /v1/reviews — создание отзыва.
|
||||
-spec create_review(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
create_review(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
Decoded when is_map(Decoded) ->
|
||||
case Decoded of
|
||||
#{<<"target_type">> := TargetTypeBin,
|
||||
<<"target_id">> := TargetId,
|
||||
<<"rating">> := Rating,
|
||||
<<"comment">> := Comment} ->
|
||||
<<"target_id">> := TargetId,
|
||||
<<"rating">> := Rating,
|
||||
<<"comment">> := Comment} ->
|
||||
TargetType = parse_target_type(TargetTypeBin),
|
||||
case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of
|
||||
{ok, Review} ->
|
||||
Response = review_to_json(Review),
|
||||
send_json(Req2, 201, Response);
|
||||
Response = handler_utils:review_to_json(Review),
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, already_reviewed} ->
|
||||
send_error(Req2, 409, <<"Already reviewed">>);
|
||||
handler_utils:send_error(Req2, 409, <<"Already reviewed">>);
|
||||
{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} ->
|
||||
send_error(Req2, 404, <<"Target not found">>);
|
||||
handler_utils:send_error(Req2, 404, <<"Target not found">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing required fields">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Missing required fields: target_type, target_id, rating, comment">>)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% GET /v1/reviews - список отзывов для цели
|
||||
%% @doc GET /v1/reviews — список отзывов для цели.
|
||||
-spec list_reviews(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_reviews(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, 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, _} ->
|
||||
send_error(Req1, 400, <<"Missing target_type">>);
|
||||
handler_utils:send_error(Req1, 400, <<"Missing target_type">>);
|
||||
{_, undefined} ->
|
||||
send_error(Req1, 400, <<"Missing target_id">>);
|
||||
handler_utils:send_error(Req1, 400, <<"Missing target_id">>);
|
||||
{TargetTypeBin, TargetId} ->
|
||||
TargetType = parse_target_type(TargetTypeBin),
|
||||
case logic_review:list_reviews(UserId, TargetType, TargetId) of
|
||||
{ok, Reviews} ->
|
||||
Response = [review_to_json(R) || R <- Reviews],
|
||||
send_json(Req1, 200, Response);
|
||||
Response = [handler_utils:review_to_json(R) || R <- Reviews],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
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(_) -> 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, []}.
|
||||
parse_target_type(_) -> undefined.
|
||||
@@ -1,106 +1,143 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик полнотекстового поиска (клиентский API).
|
||||
%%%
|
||||
%%% GET – выполняет поиск по календарям и событиям.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_search).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> search(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> search(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% @doc GET /v1/search — полнотекстовый поиск с фильтрами.
|
||||
-spec search(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
search(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, 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),
|
||||
|
||||
Params = parse_params(Qs),
|
||||
|
||||
case logic_search:search(Type, Query, UserId, Params) of
|
||||
{ok, Total, Results} ->
|
||||
Response = #{
|
||||
total => Total,
|
||||
limit => maps:get(limit, Params, 20),
|
||||
offset => maps:get(offset, Params, 0),
|
||||
total => Total,
|
||||
limit => maps:get(limit, Params, 20),
|
||||
offset => maps:get(offset, Params, 0),
|
||||
results => Results
|
||||
},
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Search failed">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Search failed">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Собирает карту параметров для поискового движка.
|
||||
-spec parse_params(cowboy_req:qs()) -> map().
|
||||
parse_params(Qs) ->
|
||||
Params = #{
|
||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||
offset => parse_int_param(Qs, <<"offset">>, 0),
|
||||
tags => proplists:get_value(<<"tags">>, Qs),
|
||||
sort => proplists:get_value(<<"sort">>, Qs, <<"start_time">>),
|
||||
order => proplists:get_value(<<"order">>, Qs, <<"asc">>)
|
||||
tags => proplists:get_value(<<"tags">>, Qs),
|
||||
sort => proplists:get_value(<<"sort">>, Qs, <<"start_time">>),
|
||||
order => proplists:get_value(<<"order">>, Qs, <<"asc">>)
|
||||
},
|
||||
|
||||
Params1 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||
{{ok, Lat}, {ok, Lon}} ->
|
||||
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
||||
Params#{lat => Lat, lon => Lon, radius => Radius};
|
||||
_ -> Params
|
||||
end,
|
||||
|
||||
Params2 = case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||
{{ok, From}, {ok, To}} ->
|
||||
Params1#{from => From, to => To};
|
||||
{{ok, From}, error} ->
|
||||
Params1#{from => From};
|
||||
{error, {ok, To}} ->
|
||||
Params1#{to => To};
|
||||
_ -> Params1
|
||||
{{ok, From}, {ok, To}} -> Params1#{from => From, to => To};
|
||||
{{ok, From}, error} -> Params1#{from => From};
|
||||
{error, {ok, To}} -> Params1#{to => To};
|
||||
_ -> Params1
|
||||
end,
|
||||
|
||||
Params2.
|
||||
|
||||
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
||||
parse_int_param(Qs, Key, Default) ->
|
||||
case proplists:get_value(Key, Qs) of
|
||||
undefined -> Default;
|
||||
Val -> binary_to_integer(Val)
|
||||
end.
|
||||
handler_utils:parse_int_qs(proplists:get_value(Key, Qs), Default).
|
||||
|
||||
-spec parse_float_param(cowboy_req:qs(), binary()) -> {ok, float()} | error.
|
||||
parse_float_param(Qs, Key) ->
|
||||
case proplists:get_value(Key, Qs) of
|
||||
undefined -> error;
|
||||
Val -> {ok, binary_to_float(Val)}
|
||||
end.
|
||||
|
||||
-spec parse_datetime_param(cowboy_req:qs(), binary()) -> {ok, calendar:datetime()} | error.
|
||||
parse_datetime_param(Qs, Key) ->
|
||||
case proplists:get_value(Key, Qs) of
|
||||
undefined -> error;
|
||||
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.
|
||||
|
||||
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, []}.
|
||||
Val -> handler_utils:parse_datetime(Val)
|
||||
end.
|
||||
@@ -1,35 +1,114 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик подписки текущего пользователя (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить информацию о подписке.
|
||||
%%% POST – активировать подписку или начать пробный период.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_subscription).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_subscription(Req);
|
||||
<<"GET">> -> get_subscription(Req);
|
||||
<<"POST">> -> create_subscription(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% GET /v1/subscription - получить подписку текущего пользователя
|
||||
%% @doc GET /v1/subscription — получить подписку текущего пользователя.
|
||||
-spec get_subscription(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_subscription(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_subscription:get_user_subscription(UserId) of
|
||||
{ok, Subscription} ->
|
||||
send_json(Req1, 200, Subscription);
|
||||
handler_utils:send_json(Req1, 200, subscription_to_json(Subscription));
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% POST /v1/subscription - активировать подписку
|
||||
%% @doc POST /v1/subscription — активировать подписку.
|
||||
-spec create_subscription(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
create_subscription(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
@@ -39,11 +118,11 @@ create_subscription(Req) ->
|
||||
case logic_subscription:start_trial(UserId) of
|
||||
{ok, Subscription} ->
|
||||
Response = subscription_to_json(Subscription),
|
||||
send_json(Req2, 201, Response);
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, already_has_subscription} ->
|
||||
send_error(Req2, 409, <<"Already has active subscription">>);
|
||||
handler_utils:send_error(Req2, 409, <<"Already has active subscription">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
end;
|
||||
#{<<"action">> := <<"activate">>, <<"plan">> := PlanBin} ->
|
||||
Plan = parse_plan(PlanBin),
|
||||
@@ -51,54 +130,39 @@ create_subscription(Req) ->
|
||||
case logic_subscription:activate_subscription(UserId, Plan, PaymentInfo) of
|
||||
{ok, Subscription} ->
|
||||
Response = subscription_to_json(Subscription),
|
||||
send_json(Req2, 201, Response);
|
||||
handler_utils:send_json(Req2, 201, Response);
|
||||
{error, payment_failed} ->
|
||||
send_error(Req2, 402, <<"Payment failed">>);
|
||||
handler_utils:send_error(Req2, 402, <<"Payment failed">>);
|
||||
{error, _} ->
|
||||
send_error(Req2, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||
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;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
catch
|
||||
_:_ ->
|
||||
send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
parse_plan(<<"monthly">>) -> 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.
|
||||
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) ->
|
||||
#{
|
||||
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)
|
||||
};
|
||||
handler_utils:subscription_to_json(Subscription);
|
||||
subscription_to_json(Subscription) when is_map(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, []}.
|
||||
Subscription.
|
||||
@@ -1,94 +1,90 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик конкретного тикета (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить тикет по ID (только для автора).
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_ticket_by_id).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_ticket(Req);
|
||||
<<"PUT">> -> update_ticket(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, Opts) ->
|
||||
handle(Req, Opts).
|
||||
|
||||
get_ticket(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
TicketId = cowboy_req:binding(id, Req1),
|
||||
io:format("[TICKET_BY_ID] User ~s requests ticket ~s~n", [UserId, TicketId]),
|
||||
case core_ticket:get_by_id(TicketId) of
|
||||
{ok, Ticket} ->
|
||||
io:format("[TICKET_BY_ID] Found ticket, reporter_id: ~s~n", [Ticket#ticket.reporter_id]),
|
||||
case Ticket#ticket.reporter_id =:= UserId of
|
||||
true ->
|
||||
io:format("[TICKET_BY_ID] Access granted~n"),
|
||||
send_json(Req1, 200, ticket_to_json(Ticket));
|
||||
false ->
|
||||
io:format("[TICKET_BY_ID] Access denied~n"),
|
||||
send_error(Req1, 403, <<"Access denied">>)
|
||||
end;
|
||||
{error, not_found} ->
|
||||
io:format("[TICKET_BY_ID] Ticket not found~n"),
|
||||
send_error(Req1, 404, <<"Ticket not found">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
io:format("[TICKET_BY_ID] Auth error: ~p - ~s~n", [Code, Message]),
|
||||
send_error(Req1, Code, Message)
|
||||
end.
|
||||
-spec trails() -> [map()].
|
||||
trails() ->
|
||||
[
|
||||
#{
|
||||
path => <<"/v1/tickets/:id">>,
|
||||
method => <<"GET">>,
|
||||
description => <<"Get a user's own ticket by ID">>,
|
||||
tags => [<<"Tickets">>],
|
||||
parameters => [
|
||||
#{
|
||||
name => <<"id">>,
|
||||
in => <<"path">>,
|
||||
description => <<"Ticket ID">>,
|
||||
required => true,
|
||||
schema => #{type => string}
|
||||
}
|
||||
],
|
||||
responses => #{
|
||||
200 => #{
|
||||
description => <<"Ticket details">>,
|
||||
content => #{<<"application/json">> => #{schema => ticket_schema()}}
|
||||
},
|
||||
403 => #{description => <<"Access denied (not the reporter)">>},
|
||||
404 => #{description => <<"Ticket not found">>}
|
||||
}
|
||||
}
|
||||
].
|
||||
|
||||
update_ticket(Req) ->
|
||||
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) ->
|
||||
ticket_schema() ->
|
||||
#{
|
||||
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
|
||||
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}
|
||||
}
|
||||
}.
|
||||
|
||||
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.
|
||||
%%% HTTP-методы
|
||||
handle(Req, _Opts) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> get_ticket(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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, []}.
|
||||
-spec get_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_ticket(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
TicketId = cowboy_req:binding(id, Req1),
|
||||
case logic_ticket:get_user_ticket(UserId, TicketId) of
|
||||
{ok, Ticket} ->
|
||||
handler_utils:send_json(Req1, 200, handler_utils:ticket_to_json(Ticket));
|
||||
{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).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
-export([trails/0]).
|
||||
|
||||
-include("records.hrl").
|
||||
|
||||
init(Req0, Opts) ->
|
||||
handle(Req0, Opts).
|
||||
%%% 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() ->
|
||||
[
|
||||
#{ % 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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_tickets(Req);
|
||||
<<"GET">> -> list_tickets(Req);
|
||||
<<"POST">> -> create_ticket(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
%% @doc GET /v1/tickets — список тикетов.
|
||||
-spec list_tickets(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
list_tickets(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case admin_utils:is_admin(UserId) of
|
||||
true ->
|
||||
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 ->
|
||||
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;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% @doc POST /v1/tickets — создание тикета.
|
||||
-spec create_ticket(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
create_ticket(Req) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
{ok, Body, Req2} = cowboy_req:read_body(Req1),
|
||||
try jsx:decode(Body, [return_maps]) of
|
||||
#{<<"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
|
||||
{ok, Ticket} ->
|
||||
send_json(Req2, 201, ticket_to_json(Ticket));
|
||||
handler_utils:send_json(Req2, 201, handler_utils:ticket_to_json(Ticket));
|
||||
{error, Reason} ->
|
||||
send_error(Req2, 500, Reason)
|
||||
handler_utils:send_error(Req2, 500, Reason)
|
||||
end;
|
||||
_ ->
|
||||
send_error(Req2, 400, <<"Missing 'error_message' field">>)
|
||||
handler_utils:send_error(Req2, 400, <<"Missing 'error_message' field">>)
|
||||
catch
|
||||
_:_ -> send_error(Req2, 400, <<"Invalid JSON">>)
|
||||
_:_ -> handler_utils: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,
|
||||
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, []}.
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -1,57 +1,105 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик бронирований текущего пользователя (клиентский API).
|
||||
%%% GET – возвращает список всех бронирований, сделанных пользователем.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_bookings).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_user_bookings(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_user_bookings(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_booking:list_user_bookings(UserId) of
|
||||
{ok, Bookings} ->
|
||||
Response = [booking_to_json(B) || B <- Bookings],
|
||||
send_json(Req1, 200, Response);
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
|
||||
%% Вспомогательные функции
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
%% @private Формирует JSON-представление записи #booking{}.
|
||||
-spec booking_to_json(#booking{}) -> map().
|
||||
booking_to_json(Booking) ->
|
||||
#{
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
|
||||
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, []}.
|
||||
id => Booking#booking.id,
|
||||
event_id => Booking#booking.event_id,
|
||||
user_id => Booking#booking.user_id,
|
||||
status => Booking#booking.status,
|
||||
notes => Booking#booking.notes,
|
||||
reminder_sent => Booking#booking.reminder_sent,
|
||||
confirmed_at => case Booking#booking.confirmed_at of
|
||||
undefined -> null;
|
||||
Dt -> handler_utils:datetime_to_iso8601(Dt)
|
||||
end,
|
||||
created_at => handler_utils:datetime_to_iso8601(Booking#booking.created_at),
|
||||
updated_at => handler_utils:datetime_to_iso8601(Booking#booking.updated_at)
|
||||
}.
|
||||
@@ -1,56 +1,87 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик профиля текущего пользователя (клиентский API).
|
||||
%%%
|
||||
%%% GET – получить информацию о своём профиле.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_me).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> ->
|
||||
case authenticate(Req) of
|
||||
{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">>)
|
||||
<<"GET">> -> get_me(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
end.
|
||||
|
||||
authenticate(Req) ->
|
||||
case cowboy_req:parse_header(<<"authorization">>, Req) of
|
||||
{bearer, Token} ->
|
||||
case logic_auth:verify_jwt(Token) of
|
||||
{ok, UserId, _Role} -> % ← теперь возвращается {ok, UserId, Role}
|
||||
{ok, UserId, Req};
|
||||
{error, expired} ->
|
||||
{error, 401, <<"Token expired">>, Req};
|
||||
{error, _} ->
|
||||
{error, 401, <<"Invalid token">>, Req}
|
||||
%% @doc GET /v1/user/me — получение профиля текущего пользователя.
|
||||
-spec get_me(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
|
||||
get_me(Req) ->
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case core_user:get_by_id(UserId) of
|
||||
{ok, User} ->
|
||||
handler_utils:send_json(Req1, 200, handler_utils:user_to_json(User));
|
||||
{error, not_found} ->
|
||||
handler_utils:send_error(Req1, 404, <<"User not found">>)
|
||||
end;
|
||||
_ ->
|
||||
{error, 401, <<"Missing or invalid Authorization header">>, Req}
|
||||
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, []}.
|
||||
{error, Code, Message, Req1} ->
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -1,56 +1,86 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Обработчик отзывов текущего пользователя (клиентский API).
|
||||
%%% GET – возвращает список всех отзывов, оставленных пользователем.
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(handler_user_reviews).
|
||||
-include("records.hrl").
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-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) ->
|
||||
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) ->
|
||||
case cowboy_req:method(Req) of
|
||||
<<"GET">> -> list_user_reviews(Req);
|
||||
_ -> send_error(Req, 405, <<"Method not allowed">>)
|
||||
<<"GET">> -> list_user_reviews(Req);
|
||||
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||
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) ->
|
||||
case handler_auth:authenticate(Req) of
|
||||
case handler_utils:auth_user(Req) of
|
||||
{ok, UserId, Req1} ->
|
||||
case logic_review:list_user_reviews(UserId) of
|
||||
{ok, Reviews} ->
|
||||
Response = [review_to_json(R) || R <- Reviews],
|
||||
send_json(Req1, 200, Response);
|
||||
Response = [handler_utils:review_to_json(R) || R <- Reviews],
|
||||
handler_utils:send_json(Req1, 200, Response);
|
||||
{error, _} ->
|
||||
send_error(Req1, 500, <<"Internal server error">>)
|
||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||
end;
|
||||
{error, Code, Message, Req1} ->
|
||||
send_error(Req1, Code, Message)
|
||||
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, []}.
|
||||
handler_utils:send_error(Req1, Code, Message)
|
||||
end.
|
||||
@@ -17,6 +17,7 @@
|
||||
parse_int_qs/2,
|
||||
parse_datetime_qs/1,
|
||||
parse_datetime/1,
|
||||
datetime_to_iso8601/1,
|
||||
event_to_json/1,
|
||||
user_to_json/1,
|
||||
review_to_json/1,
|
||||
@@ -117,18 +118,31 @@ parse_datetime_qs(Bin) ->
|
||||
-spec parse_datetime(binary()) -> {ok, calendar:datetime()} | {error, invalid_format}.
|
||||
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(list_to_binary(YearStr)),
|
||||
Month = binary_to_integer(list_to_binary(MonthStr)),
|
||||
Day = binary_to_integer(list_to_binary(DayStr)),
|
||||
Hour = binary_to_integer(list_to_binary(HourStr)),
|
||||
Minute = binary_to_integer(list_to_binary(MinuteStr)),
|
||||
Second = binary_to_integer(list_to_binary(SecondStr)),
|
||||
%% Убираем завершающий 'Z', если он есть
|
||||
Clean = case binary:last(Str) of
|
||||
$Z -> binary_part(Str, 0, byte_size(Str) - 1);
|
||||
_ -> Str
|
||||
end,
|
||||
%% Разделяем дату и время
|
||||
[DatePart, TimePart] = binary:split(Clean, <<"T">>),
|
||||
%% Парсим дату YYYY-MM-DD
|
||||
[YearStr, MonthStr, DayStr] = binary:split(DatePart, <<"-">>, [global]),
|
||||
%% Убираем дробные секунды, если есть
|
||||
TimeMain = case binary:split(TimePart, <<".">>) of
|
||||
[Main, _] -> Main;
|
||||
[Main] -> Main
|
||||
end,
|
||||
%% Парсим время HH:MM:SS
|
||||
[HourStr, MinuteStr, SecondStr] = binary:split(TimeMain, <<":">>, [global]),
|
||||
Year = binary_to_integer(YearStr),
|
||||
Month = binary_to_integer(MonthStr),
|
||||
Day = binary_to_integer(DayStr),
|
||||
Hour = binary_to_integer(HourStr),
|
||||
Minute = binary_to_integer(MinuteStr),
|
||||
Second = binary_to_integer(SecondStr),
|
||||
{ok, {{Year, Month, Day}, {Hour, Minute, Second}}}
|
||||
catch _:_ -> {error, invalid_format}
|
||||
catch
|
||||
_:_ -> {error, invalid_format}
|
||||
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).
|
||||
-behaviour(cowboy_handler).
|
||||
|
||||
-export([init/2]).
|
||||
|
||||
%%% cowboy_handler callback
|
||||
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||
init(Req, _Opts) ->
|
||||
Path = cowboy_req:path(Req),
|
||||
handle(Path, Req).
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Роутинг путей
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
-spec handle(binary(), cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
handle(<<"/">>, Req) ->
|
||||
serve_index(Req);
|
||||
handle(<<"/admin">>, Req) ->
|
||||
@@ -25,7 +45,11 @@ handle(_, Req) ->
|
||||
cowboy_req:reply(404, #{}, <<"Not Found">>, Req),
|
||||
{ok, [], []}.
|
||||
|
||||
%% Главная страница с выбором API
|
||||
%%--------------------------------------------------------------------
|
||||
%% Главная страница
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
-spec serve_index(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
serve_index(Req) ->
|
||||
Html = <<"<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -41,7 +65,11 @@ serve_index(Req) ->
|
||||
cowboy_req:reply(200, #{<<"content-type">> => <<"text/html">>}, Html, Req),
|
||||
{ok, Html, []}.
|
||||
|
||||
%% Swagger UI для конкретного API
|
||||
%%--------------------------------------------------------------------
|
||||
%% Swagger UI
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
-spec serve_ui(admin | client, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
serve_ui(Api, Req) ->
|
||||
{Title, SpecUrl} = case Api of
|
||||
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),
|
||||
{ok, Html, []}.
|
||||
|
||||
%% Генерация OpenAPI JSON
|
||||
%%--------------------------------------------------------------------
|
||||
%% OpenAPI JSON
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
-spec serve_json(admin | client, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
serve_json(Api, Req) ->
|
||||
Trails = case Api of
|
||||
admin -> trails:admin();
|
||||
@@ -81,6 +113,11 @@ serve_json(Api, Req) ->
|
||||
cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Json, Req),
|
||||
{ok, Json, []}.
|
||||
|
||||
%%--------------------------------------------------------------------
|
||||
%% Вспомогательные функции
|
||||
%%--------------------------------------------------------------------
|
||||
|
||||
-spec build_paths([map()]) -> map().
|
||||
build_paths(Trails) ->
|
||||
lists:foldl(fun(Trail, Acc) ->
|
||||
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})
|
||||
end, #{}, Trails).
|
||||
|
||||
-spec redirect_to_slash(binary(), cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||
redirect_to_slash(Location, Req) ->
|
||||
cowboy_req:reply(301, #{<<"location">> => Location}, <<>>, Req),
|
||||
{ok, [], []}.
|
||||
@@ -1,5 +1,18 @@
|
||||
%%%-------------------------------------------------------------------
|
||||
%%% @doc Пользовательский WebSocket-обработчик.
|
||||
%%%
|
||||
%%% Устанавливает WebSocket-соединение после проверки JWT-токена
|
||||
%%% и подписывает пользователя на обновления календарей.
|
||||
%%%
|
||||
%%% Поддерживаемые действия:
|
||||
%%% - subscribe – подписаться на обновления календаря
|
||||
%%% - unsubscribe – отписаться от обновлений
|
||||
%%% - ping – проверка соединения
|
||||
%%% @end
|
||||
%%%-------------------------------------------------------------------
|
||||
-module(ws_handler).
|
||||
-behaviour(cowboy_websocket).
|
||||
|
||||
-export([init/2]).
|
||||
-export([websocket_init/1]).
|
||||
-export([websocket_handle/2]).
|
||||
@@ -7,59 +20,73 @@
|
||||
-export([terminate/3]).
|
||||
|
||||
-record(state, {
|
||||
user_id :: binary() | undefined,
|
||||
subscriptions = [] :: [binary()]
|
||||
user_id :: binary() | undefined,
|
||||
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) ->
|
||||
Qs = cowboy_req:parse_qs(Req),
|
||||
case proplists:get_value(<<"token">>, Qs) of
|
||||
undefined ->
|
||||
{ok, cowboy_req:reply(401, #{}, <<"Missing token">>, Req), undefined};
|
||||
Req1 = cowboy_req:reply(401, #{}, <<"Missing token">>, Req),
|
||||
{ok, Req1, undefined};
|
||||
Token ->
|
||||
case logic_auth:verify_jwt(Token) of
|
||||
{ok, UserId, _Role} ->
|
||||
{cowboy_websocket, Req, #state{user_id = UserId}};
|
||||
{error, _} ->
|
||||
{ok, cowboy_req:reply(401, #{}, <<"Invalid token">>, Req), undefined}
|
||||
Req1 = cowboy_req:reply(401, #{}, <<"Invalid token">>, Req),
|
||||
{ok, Req1, undefined}
|
||||
end
|
||||
end.
|
||||
|
||||
websocket_init(State) ->
|
||||
pg:join(eventhub_ws, self()),
|
||||
{ok, State}.
|
||||
%%%===================================================================
|
||||
%%% websocket callbacks
|
||||
%%%===================================================================
|
||||
|
||||
-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) ->
|
||||
io:format("WebSocket received: ~s~n", [Msg]),
|
||||
io:format("[WS] Received: ~s~n", [Msg]),
|
||||
try jsx:decode(Msg, [return_maps]) of
|
||||
#{<<"action">> := <<"subscribe">>, <<"calendar_id">> := CalendarId} ->
|
||||
io:format("Subscribe to calendar: ~s~n", [CalendarId]),
|
||||
NewSubs = case lists:member(CalendarId, State#state.subscriptions) of
|
||||
true -> State#state.subscriptions;
|
||||
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}};
|
||||
handle_subscribe(CalendarId, State);
|
||||
#{<<"action">> := <<"unsubscribe">>, <<"calendar_id">> := CalendarId} ->
|
||||
handle_unsubscribe(CalendarId, State);
|
||||
#{<<"action">> := <<"ping">>} ->
|
||||
{reply, {text, <<"{\"status\":\"pong\"}">>}, State};
|
||||
Other ->
|
||||
io:format("Unknown action: ~p~n", [Other]),
|
||||
io:format("[WS] Unknown action: ~p~n", [Other]),
|
||||
{ok, State}
|
||||
catch
|
||||
_:Error ->
|
||||
io:format("Error parsing WebSocket message: ~p~n", [Error]),
|
||||
io:format("[WS] Error parsing message: ~p~n", [Error]),
|
||||
{ok, State}
|
||||
end;
|
||||
websocket_handle(_Frame, State) ->
|
||||
{ok, State}.
|
||||
|
||||
-spec websocket_info(term(), #state{}) ->
|
||||
{ok, #state{}} | {reply, {text, binary()}, #state{}}.
|
||||
websocket_info({notification, Type, Data}, State) ->
|
||||
case should_notify(Type, Data, State) of
|
||||
true ->
|
||||
Msg = jsx:encode(#{
|
||||
type => Type,
|
||||
data => Data,
|
||||
type => Type,
|
||||
data => Data,
|
||||
timestamp => os:system_time(seconds)
|
||||
}),
|
||||
{reply, {text, Msg}, State};
|
||||
@@ -69,15 +96,41 @@ websocket_info({notification, Type, Data}, State) ->
|
||||
websocket_info(_Info, 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()),
|
||||
io:format("[WS] User ~s disconnected~n", [UserId]),
|
||||
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) ->
|
||||
lists:member(CalId, State#state.subscriptions);
|
||||
%%%===================================================================
|
||||
%%% Внутренние функции
|
||||
%%%===================================================================
|
||||
|
||||
-spec handle_subscribe(binary(), #state{}) -> {reply, {text, binary()}, #state{}}.
|
||||
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(_, _, _) ->
|
||||
true.
|
||||
@@ -11,6 +11,7 @@
|
||||
close_ticket/2,
|
||||
get_statistics/1]).
|
||||
-export([delete_ticket/2]).
|
||||
-export([get_user_ticket/2]).
|
||||
|
||||
%% Зарегистрировать ошибку (создать или обновить тикет)
|
||||
report_error(ErrorMessage, Stacktrace, Context) ->
|
||||
@@ -39,6 +40,17 @@ report_error(ErrorMessage, Stacktrace, Context) ->
|
||||
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) ->
|
||||
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() ->
|
||||
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).
|
||||
|
||||
|
||||
Reference in New Issue
Block a user