diff --git a/include/records.hrl b/include/records.hrl index b856103..d72bc7b 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -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(), diff --git a/src/handlers/handler_auth.erl b/src/handlers/handler_auth.erl index 88a7a7b..c75f1ef 100644 --- a/src/handlers/handler_auth.erl +++ b/src/handlers/handler_auth.erl @@ -1,24 +1,51 @@ +%%%------------------------------------------------------------------- +%%% @doc Единый модуль аутентификации запросов. +%%% Извлекает JWT из заголовка `Authorization: Bearer `, +%%% проверяет его и возвращает `{ok, UserId, Req}` или ошибку. +%%% Используется модулем `handler_utils` и другими обработчиками. +%%% @end +%%%------------------------------------------------------------------- -module(handler_auth). -export([authenticate/1]). +%%%=================================================================== +%%% API +%%%=================================================================== + +%% @doc Извлекает и проверяет Bearer token из запроса Cowboy. +%% +%% Возвращает: +%% +-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. \ No newline at end of file diff --git a/src/handlers/handler_booking_by_id.erl b/src/handlers/handler_booking_by_id.erl index ad2a306..aab58a8 100644 --- a/src/handlers/handler_booking_by_id.erl +++ b/src/handlers/handler_booking_by_id.erl @@ -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, []}. \ No newline at end of file + 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) + }. \ No newline at end of file diff --git a/src/handlers/handler_bookings.erl b/src/handlers/handler_bookings.erl index d2061ab..f7ff2ec 100644 --- a/src/handlers/handler_bookings.erl +++ b/src/handlers/handler_bookings.erl @@ -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, []}. \ No newline at end of file + 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) + }. \ No newline at end of file diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl index a86fab2..5ec024a 100644 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -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, []}. \ No newline at end of file + handler_utils:send_error(Req1, Code, Message) + end. \ No newline at end of file diff --git a/src/handlers/handler_calendar_view.erl b/src/handlers/handler_calendar_view.erl index fede895..746ecd0 100644 --- a/src/handlers/handler_calendar_view.erl +++ b/src/handlers/handler_calendar_view.erl @@ -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. \ No newline at end of file diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index 034cd70..c356e60 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -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, []}. \ No newline at end of file +-spec parse_type(binary()) -> personal | commercial. +parse_type(<<"personal">>) -> personal; +parse_type(<<"commercial">>) -> commercial; +parse_type(_) -> personal. \ No newline at end of file diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl index 5b8fabf..212fced 100644 --- a/src/handlers/handler_event_by_id.erl +++ b/src/handlers/handler_event_by_id.erl @@ -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, []}. \ No newline at end of file + error:badarg -> {status, Val} + end; +convert_field(Other) -> Other. \ No newline at end of file diff --git a/src/handlers/handler_event_occurrences.erl b/src/handlers/handler_event_occurrences.erl index cc91ab2..1627c50 100644 --- a/src/handlers/handler_event_occurrences.erl +++ b/src/handlers/handler_event_occurrences.erl @@ -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, []}. \ No newline at end of file + is_virtual => false, + status => Event#event.status + }. \ No newline at end of file diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl index 5d506bf..7782272 100644 --- a/src/handlers/handler_events.erl +++ b/src/handlers/handler_events.erl @@ -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, []}. \ No newline at end of file + }. \ No newline at end of file diff --git a/src/handlers/handler_health.erl b/src/handlers/handler_health.erl index 188a1c0..26608cc 100644 --- a/src/handlers/handler_health.erl +++ b/src/handlers/handler_health.erl @@ -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. \ No newline at end of file diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl index 0dda64f..9f4b4ac 100644 --- a/src/handlers/handler_login.erl +++ b/src/handlers/handler_login.erl @@ -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, []}. \ No newline at end of file +%% @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. \ No newline at end of file diff --git a/src/handlers/handler_refresh.erl b/src/handlers/handler_refresh.erl index 5a84c37..9e28952 100644 --- a/src/handlers/handler_refresh.erl +++ b/src/handlers/handler_refresh.erl @@ -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, []}. \ No newline at end of file + {ok, #{token => NewToken, refresh_token => NewRefreshToken}}. \ No newline at end of file diff --git a/src/handlers/handler_register.erl b/src/handlers/handler_register.erl index e839623..58170a3 100644 --- a/src/handlers/handler_register.erl +++ b/src/handlers/handler_register.erl @@ -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, []}. \ No newline at end of file +%% @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. \ No newline at end of file diff --git a/src/handlers/handler_reports.erl b/src/handlers/handler_reports.erl index 8fe14aa..a612299 100644 --- a/src/handlers/handler_reports.erl +++ b/src/handlers/handler_reports.erl @@ -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, []}. \ No newline at end of file +parse_target_type(<<"review">>) -> review; +parse_target_type(_) -> undefined. \ No newline at end of file diff --git a/src/handlers/handler_review_by_id.erl b/src/handlers/handler_review_by_id.erl index 4b46f51..e2a8f1f 100644 --- a/src/handlers/handler_review_by_id.erl +++ b/src/handlers/handler_review_by_id.erl @@ -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, []}. \ No newline at end of file + handler_utils:send_error(Req1, Code, Message) + end. \ No newline at end of file diff --git a/src/handlers/handler_reviews.erl b/src/handlers/handler_reviews.erl index e8b6452..96711c7 100644 --- a/src/handlers/handler_reviews.erl +++ b/src/handlers/handler_reviews.erl @@ -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, []}. \ No newline at end of file +parse_target_type(_) -> undefined. \ No newline at end of file diff --git a/src/handlers/handler_search.erl b/src/handlers/handler_search.erl index d21c2b2..89c5ed2 100644 --- a/src/handlers/handler_search.erl +++ b/src/handlers/handler_search.erl @@ -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, []}. \ No newline at end of file + Val -> handler_utils:parse_datetime(Val) + end. \ No newline at end of file diff --git a/src/handlers/handler_subscription.erl b/src/handlers/handler_subscription.erl index b1852b3..733eeeb 100644 --- a/src/handlers/handler_subscription.erl +++ b/src/handlers/handler_subscription.erl @@ -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, []}. \ No newline at end of file + Subscription. \ No newline at end of file diff --git a/src/handlers/handler_ticket_by_id.erl b/src/handlers/handler_ticket_by_id.erl index fc4bc65..0e7872c 100644 --- a/src/handlers/handler_ticket_by_id.erl +++ b/src/handlers/handler_ticket_by_id.erl @@ -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, []}. \ No newline at end of file +-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. \ No newline at end of file diff --git a/src/handlers/handler_tickets.erl b/src/handlers/handler_tickets.erl index 53d47be..7e43bb7 100644 --- a/src/handlers/handler_tickets.erl +++ b/src/handlers/handler_tickets.erl @@ -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, []}. \ No newline at end of file + handler_utils:send_error(Req1, Code, Message) + end. \ No newline at end of file diff --git a/src/handlers/handler_user_bookings.erl b/src/handlers/handler_user_bookings.erl index 1bd435f..2a179f1 100644 --- a/src/handlers/handler_user_bookings.erl +++ b/src/handlers/handler_user_bookings.erl @@ -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, []}. \ No newline at end of file + 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) + }. \ No newline at end of file diff --git a/src/handlers/handler_user_me.erl b/src/handlers/handler_user_me.erl index 842783c..5d52669 100644 --- a/src/handlers/handler_user_me.erl +++ b/src/handlers/handler_user_me.erl @@ -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, []}. \ No newline at end of file + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. \ No newline at end of file diff --git a/src/handlers/handler_user_reviews.erl b/src/handlers/handler_user_reviews.erl index 66177f7..d4bc8a7 100644 --- a/src/handlers/handler_user_reviews.erl +++ b/src/handlers/handler_user_reviews.erl @@ -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, []}. \ No newline at end of file + handler_utils:send_error(Req1, Code, Message) + end. \ No newline at end of file diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl index 5e0e53c..ee3b478 100644 --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -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. %%%=================================================================== diff --git a/src/handlers/swagger_docs_handler.erl b/src/handlers/swagger_docs_handler.erl index e53ac42..0e52499 100644 --- a/src/handlers/swagger_docs_handler.erl +++ b/src/handlers/swagger_docs_handler.erl @@ -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 = <<" @@ -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, [], []}. \ No newline at end of file diff --git a/src/handlers/ws_handler.erl b/src/handlers/ws_handler.erl index 57101d8..3f2ee6d 100644 --- a/src/handlers/ws_handler.erl +++ b/src/handlers/ws_handler.erl @@ -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. \ No newline at end of file diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index d21dfe7..4ab4413 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -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 diff --git a/src/swagger/admin-swagger.json b/src/swagger/admin-swagger.json new file mode 100644 index 0000000..b428277 --- /dev/null +++ b/src/swagger/admin-swagger.json @@ -0,0 +1,2813 @@ +{ + "info": { + "version": "1.0.0", + "title": "EventHub Admin API" + }, + "paths": { + "/v1/admin/admins": { + "get": { + "description": "List all admins (superadmin only)", + "tags": [ + "Admins" + ], + "responses": { + "200": { + "description": "Array of admins", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "blocked" + ] + }, + "role": { + "type": "string", + "enum": [ + "superadmin", + "admin", + "moderator", + "support" + ] + }, + "email": { + "type": "string", + "format": "email" + }, + "nickname": { + "type": "string", + "nullable": true + }, + "avatar_url": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string", + "nullable": true + }, + "language": { + "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" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "role", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer" + } + } + ] + } + }, + "/v1/admin/audit": { + "get": { + "description": "List audit records (admin)", + "tags": [ + "Audit" + ], + "responses": { + "200": { + "description": "Array of audit records", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "ip": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + }, + "admin_id": { + "type": "string" + }, + "action": { + "type": "string" + }, + "entity_type": { + "type": "string" + }, + "entity_id": { + "type": "string" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "admin_id", + "description": "Filter by admin ID", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "action", + "description": "Filter by action", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "entity_type", + "description": "Filter by entity type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "from", + "description": "Start timestamp (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "to", + "description": "End timestamp (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ] + } + }, + "/v1/admin/banned-words": { + "get": { + "description": "List all banned words (admin)", + "tags": [ + "Banned Words" + ], + "responses": { + "200": { + "description": "Array of banned words", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "added_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "word": { + "type": "string" + }, + "added_by": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "description": "Add a new banned word", + "tags": [ + "Banned Words" + ], + "responses": { + "201": { + "description": "Word added" + }, + "409": { + "description": "Word already exists" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "word": { + "type": "string" + } + }, + "required": [ + "word" + ] + } + } + }, + "required": true + } + } + }, + "/v1/admin/banned-words/:word": { + "delete": { + "description": "Remove a banned word", + "tags": [ + "Banned Words" + ], + "responses": { + "200": { + "description": "Word removed" + }, + "404": { + "description": "Word not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "word", + "description": "The word to remove", + "schema": { + "type": "string" + }, + "required": true + } + ] + } + }, + "/v1/admin/calendar/:id": { + "put": { + "description": "Moderate calendar - unfreeze", + "tags": [ + "Moderation" + ], + "responses": { + "200": { + "description": "Moderation applied successfully" + }, + "400": { + "description": "Bad request" + }, + "404": { + "description": "Entity not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "target_type", + "description": "Entity type", + "schema": { + "type": "string", + "enum": [ + "calendar", + "event", + "review", + "user" + ] + }, + "required": true + }, + { + "in": "path", + "name": "id", + "description": "Entity ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "freeze", + "unfreeze" + ] + } + }, + "required": [ + "action" + ] + } + } + }, + "required": true + } + } + }, + "/v1/admin/event/:id": { + "put": { + "description": "Moderate event - unfreeze", + "tags": [ + "Moderation" + ], + "responses": { + "200": { + "description": "Moderation applied successfully" + }, + "400": { + "description": "Bad request" + }, + "404": { + "description": "Entity not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "target_type", + "description": "Entity type", + "schema": { + "type": "string", + "enum": [ + "calendar", + "event", + "review", + "user" + ] + }, + "required": true + }, + { + "in": "path", + "name": "id", + "description": "Entity ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "freeze", + "unfreeze" + ] + } + }, + "required": [ + "action" + ] + } + } + }, + "required": true + } + } + }, + "/v1/admin/events": { + "get": { + "description": "Search and list events (admin)", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Array of events with Content-Range header", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "location": { + "type": "object", + "nullable": true + }, + "duration": { + "type": "integer" + }, + "calendar_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "rating_avg": { + "type": "number", + "format": "float" + }, + "rating_count": { + "type": "integer" + }, + "event_type": { + "type": "string", + "enum": [ + "single", + "recurring" + ] + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "master_id": { + "type": "string", + "nullable": true + }, + "is_instance": { + "type": "boolean" + }, + "specialist_id": { + "type": "string", + "nullable": true + }, + "capacity": { + "type": "integer", + "nullable": true + }, + "online_link": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "edit_history": { + "type": "array", + "items": { + "type": "object" + }, + "nullable": true + }, + "recurrence": { + "type": "object", + "nullable": true + } + } + } + } + } + } + }, + "405": { + "description": "Method not allowed" + } + }, + "parameters": [ + { + "in": "query", + "name": "from", + "description": "ISO8601 start datetime", + "schema": { + "type": "string" + }, + "required": false + }, + { + "in": "query", + "name": "to", + "description": "ISO8601 end datetime", + "schema": { + "type": "string" + }, + "required": false + }, + { + "in": "query", + "name": "status", + "description": "active, cancelled, completed, or all", + "schema": { + "type": "string" + }, + "required": false + }, + { + "in": "query", + "name": "calendar_id", + "description": "Filter by calendar ID", + "schema": { + "type": "string" + }, + "required": false + }, + { + "in": "query", + "name": "title", + "description": "Exact title match", + "schema": { + "type": "string" + }, + "required": false + }, + { + "in": "query", + "name": "q", + "description": "Substring search in title/description", + "schema": { + "type": "string" + }, + "required": false + }, + { + "in": "query", + "name": "limit", + "description": "Page size (max 200)", + "schema": { + "type": "integer" + }, + "required": false + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + }, + "required": false + }, + { + "in": "query", + "name": "sort", + "description": "created_at, start_time, title, status", + "schema": { + "type": "string" + }, + "required": false + }, + { + "in": "query", + "name": "order", + "description": "asc or desc", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + }, + "required": false + } + ] + } + }, + "/v1/admin/events/:id": { + "delete": { + "description": "Soft-delete event (admin)", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Event status set to deleted" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get event by ID (admin)", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Event details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "location": { + "type": "object", + "nullable": true + }, + "duration": { + "type": "integer" + }, + "calendar_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "rating_avg": { + "type": "number", + "format": "float" + }, + "rating_count": { + "type": "integer" + }, + "event_type": { + "type": "string", + "enum": [ + "single", + "recurring" + ] + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "master_id": { + "type": "string", + "nullable": true + }, + "is_instance": { + "type": "boolean" + }, + "specialist_id": { + "type": "string", + "nullable": true + }, + "capacity": { + "type": "integer", + "nullable": true + }, + "online_link": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "edit_history": { + "type": "array", + "items": { + "type": "object" + }, + "nullable": true + }, + "recurrence": { + "type": "object", + "nullable": true + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update event (admin)", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Updated event" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "location": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "lat": { + "type": "number", + "format": "float" + }, + "lon": { + "type": "number", + "format": "float" + } + } + }, + "duration": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "specialist_id": { + "type": "string" + }, + "capacity": { + "type": "integer" + }, + "online_link": { + "type": "string" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/admin/health": { + "get": { + "description": "Admin API health check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "API is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/admin/login": { + "post": { + "description": "Admin login", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Login successful, returns token and user info" + }, + "401": { + "description": "Invalid credentials" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "format": "password" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email", + "password" + ] + } + } + }, + "required": true + } + } + }, + "/v1/admin/me": { + "get": { + "description": "Get current admin profile", + "tags": [ + "Admins" + ], + "responses": { + "200": { + "description": "Admin profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string" + }, + "nickname": { + "type": "string", + "nullable": true + }, + "avatar_url": { + "type": "string", + "nullable": true + }, + "timezone": { + "type": "string", + "nullable": true + }, + "language": { + "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" + } + } + } + } + } + } + } + }, + "put": { + "description": "Update current admin profile", + "tags": [ + "Admins" + ], + "responses": { + "200": { + "description": "Updated profile" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "nickname": { + "type": "string" + }, + "avatar_url": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "language": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "preferences": { + "type": "object" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/admin/reports": { + "get": { + "description": "List all reports (admin)", + "tags": [ + "Reports" + ], + "responses": { + "200": { + "description": "Array of reports", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "reviewed", + "dismissed" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event", + "review" + ] + }, + "target_id": { + "type": "string" + }, + "reporter_id": { + "type": "string" + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resolved_by": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "status", + "description": "Filter by status", + "schema": { + "type": "string", + "enum": [ + "pending", + "reviewed", + "dismissed" + ] + } + }, + { + "in": "query", + "name": "target_type", + "description": "Filter by target type", + "schema": { + "type": "string", + "enum": [ + "calendar", + "event", + "review" + ] + } + }, + { + "in": "query", + "name": "q", + "description": "Search in reason", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ] + } + }, + "/v1/admin/reports/:id": { + "get": { + "description": "Get report by ID (admin)", + "tags": [ + "Reports" + ], + "responses": { + "200": { + "description": "Report details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "reviewed", + "dismissed" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event", + "review" + ] + }, + "target_id": { + "type": "string" + }, + "reporter_id": { + "type": "string" + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resolved_by": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "404": { + "description": "Report not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Report ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update report status (admin)", + "tags": [ + "Reports" + ], + "responses": { + "200": { + "description": "Updated report" + }, + "404": { + "description": "Report not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Report ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "reviewed", + "dismissed" + ] + } + } + } + } + }, + "required": true + } + } + }, + "/v1/admin/review/:id": { + "put": { + "description": "Moderate review - unhide", + "tags": [ + "Moderation" + ], + "responses": { + "200": { + "description": "Moderation applied successfully" + }, + "400": { + "description": "Bad request" + }, + "404": { + "description": "Entity not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "target_type", + "description": "Entity type", + "schema": { + "type": "string", + "enum": [ + "calendar", + "event", + "review", + "user" + ] + }, + "required": true + }, + { + "in": "path", + "name": "id", + "description": "Entity ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "hide", + "unhide" + ] + } + }, + "required": [ + "action" + ] + } + } + }, + "required": true + } + } + }, + "/v1/admin/reviews": { + "get": { + "description": "List all reviews (admin)", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Array of reviews", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "visible", + "hidden", + "deleted" + ] + }, + "comment": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "target_type", + "description": "calendar or event", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "target_id", + "description": "ID of target", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "user_id", + "description": "Filter by user", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "status", + "description": "visible, hidden, deleted, or all", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ] + }, + "patch": { + "description": "Bulk update review statuses", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Number of updated reviews" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "visible", + "hidden", + "deleted" + ] + } + } + } + } + } + }, + "required": true + } + } + }, + "/v1/admin/reviews/:id": { + "get": { + "description": "Get review by ID (admin)", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Review details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "visible", + "hidden", + "deleted" + ] + }, + "comment": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update review (admin)", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Updated review" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "visible", + "hidden", + "deleted" + ] + }, + "comment": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + } + } + } + } + }, + "required": true + } + } + }, + "/v1/admin/stats": { + "get": { + "description": "Get admin dashboard statistics", + "tags": [ + "Statistics" + ], + "responses": { + "200": { + "description": "Statistics object", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "active_subscriptions": { + "type": "integer" + }, + "calendars": { + "type": "integer" + }, + "events": { + "type": "integer" + }, + "reports": { + "type": "integer" + }, + "reviews": { + "type": "integer" + }, + "subscriptions": { + "type": "integer" + }, + "tickets": { + "type": "integer" + }, + "users": { + "type": "integer", + "description": "Total number of users" + } + } + } + } + } + }, + "403": { + "description": "Admin access required" + } + }, + "parameters": [ + { + "in": "query", + "name": "from", + "description": "Start date (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "to", + "description": "End date (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + } + } + ] + } + }, + "/v1/admin/subscriptions": { + "get": { + "description": "List all subscriptions (admin)", + "tags": [ + "Subscriptions" + ], + "responses": { + "200": { + "description": "Array of subscriptions", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "expired", + "cancelled" + ] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "plan": { + "type": "string", + "enum": [ + "monthly", + "quarterly", + "biannual", + "annual" + ] + }, + "trial_used": { + "type": "boolean" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "plan", + "description": "Filter by plan", + "schema": { + "type": "string", + "enum": [ + "monthly", + "quarterly", + "biannual", + "annual" + ] + } + }, + { + "in": "query", + "name": "status", + "description": "Filter by status", + "schema": { + "type": "string", + "enum": [ + "active", + "expired", + "cancelled" + ] + } + }, + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ] + } + }, + "/v1/admin/subscriptions/:id": { + "delete": { + "description": "Delete subscription (admin)", + "tags": [ + "Subscriptions" + ], + "responses": { + "200": { + "description": "Subscription deleted" + }, + "404": { + "description": "Subscription not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Subscription ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get subscription by ID (admin)", + "tags": [ + "Subscriptions" + ], + "responses": { + "200": { + "description": "Subscription details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "expired", + "cancelled" + ] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "plan": { + "type": "string", + "enum": [ + "monthly", + "quarterly", + "biannual", + "annual" + ] + }, + "trial_used": { + "type": "boolean" + } + } + } + } + } + }, + "404": { + "description": "Subscription not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Subscription ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update subscription (admin)", + "tags": [ + "Subscriptions" + ], + "responses": { + "200": { + "description": "Updated subscription" + }, + "404": { + "description": "Subscription not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Subscription ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "active", + "expired", + "cancelled" + ] + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "New expiration date" + }, + "plan": { + "type": "string", + "enum": [ + "monthly", + "quarterly", + "biannual", + "annual" + ] + }, + "trial_used": { + "type": "boolean" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/admin/tickets": { + "get": { + "description": "List all tickets (admin)", + "tags": [ + "Tickets" + ], + "responses": { + "200": { + "description": "Array of tickets", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ] + }, + "context": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "reporter_id": { + "type": "string" + }, + "error_hash": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "first_seen": { + "type": "string", + "format": "date-time" + }, + "last_seen": { + "type": "string", + "format": "date-time" + }, + "assigned_to": { + "type": "string", + "nullable": true + }, + "resolution_note": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "status", + "description": "Filter by status", + "schema": { + "type": "string", + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ] + } + }, + { + "in": "query", + "name": "assigned_to", + "description": "Filter by assigned admin ID", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "q", + "description": "Search in error message", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ] + } + }, + "/v1/admin/tickets/:id": { + "delete": { + "description": "Delete ticket (admin)", + "tags": [ + "Tickets" + ], + "responses": { + "200": { + "description": "Ticket deleted" + }, + "404": { + "description": "Ticket not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Ticket ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get ticket by ID (admin)", + "tags": [ + "Tickets" + ], + "responses": { + "200": { + "description": "Ticket details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ] + }, + "context": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "reporter_id": { + "type": "string" + }, + "error_hash": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "first_seen": { + "type": "string", + "format": "date-time" + }, + "last_seen": { + "type": "string", + "format": "date-time" + }, + "assigned_to": { + "type": "string", + "nullable": true + }, + "resolution_note": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "404": { + "description": "Ticket not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Ticket ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update ticket (admin)", + "tags": [ + "Tickets" + ], + "responses": { + "200": { + "description": "Updated ticket" + }, + "404": { + "description": "Ticket not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Ticket ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ] + }, + "assigned_to": { + "type": "string" + }, + "resolution_note": { + "type": "string" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/admin/tickets/stats": { + "get": { + "description": "Get ticket statistics (admin)", + "tags": [ + "Tickets" + ], + "responses": { + "200": { + "description": "Ticket statistics", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "closed": { + "type": "integer", + "description": "Number of closed tickets" + }, + "open": { + "type": "integer", + "description": "Number of open tickets" + }, + "total": { + "type": "integer", + "description": "Total number of tickets" + }, + "resolved": { + "type": "integer", + "description": "Number of resolved tickets" + }, + "in_progress": { + "type": "integer", + "description": "Number of tickets in progress" + } + } + } + } + } + }, + "403": { + "description": "Admin access required" + } + } + } + }, + "/v1/admin/user/:id": { + "put": { + "description": "Moderate user - unblock", + "tags": [ + "Moderation" + ], + "responses": { + "200": { + "description": "Moderation applied successfully" + }, + "400": { + "description": "Bad request" + }, + "404": { + "description": "Entity not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "target_type", + "description": "Entity type", + "schema": { + "type": "string", + "enum": [ + "calendar", + "event", + "review", + "user" + ] + }, + "required": true + }, + { + "in": "path", + "name": "id", + "description": "Entity ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "block", + "unblock" + ] + } + }, + "required": [ + "action" + ] + } + } + }, + "required": true + } + } + }, + "/v1/admin/users": { + "get": { + "description": "List all users (admin)", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Array of users", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "frozen", + "deleted" + ] + }, + "role": { + "type": "string", + "enum": [ + "user", + "bot" + ] + }, + "email": { + "type": "string", + "format": "email" + }, + "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" + } + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "query", + "name": "role", + "schema": { + "type": "string", + "enum": [ + "user", + "bot" + ] + } + }, + { + "in": "query", + "name": "status", + "schema": { + "type": "string", + "enum": [ + "active", + "frozen", + "deleted" + ] + } + }, + { + "in": "query", + "name": "q", + "description": "Search by email or nickname", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "schema": { + "type": "integer" + } + } + ] + } + }, + "/v1/admin/users/:id": { + "delete": { + "description": "Soft-delete user (admin)", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User status set to deleted" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get user by ID (admin)", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string" + }, + "role": { + "type": "string" + }, + "email": { + "type": "string" + }, + "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" + } + }, + "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" + } + } + } + } + } + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update user (admin)", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Updated user" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "frozen", + "deleted" + ] + }, + "role": { + "type": "string", + "enum": [ + "user", + "bot" + ] + }, + "nickname": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "language": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "preferences": { + "type": "object" + } + } + } + } + }, + "required": true + } + } + } + }, + "openapi": "3.0.3", + "servers": [ + { + "description": "API server", + "url": "http://localhost:8445" + } + ] +} \ No newline at end of file diff --git a/src/swagger/client-swagger.json b/src/swagger/client-swagger.json new file mode 100644 index 0000000..992199a --- /dev/null +++ b/src/swagger/client-swagger.json @@ -0,0 +1,2746 @@ +{ + "info": { + "version": "1.0.0", + "title": "EventHub Client API" + }, + "paths": { + "/v1/bookings/:id": { + "delete": { + "description": "Cancel booking (participant)", + "tags": [ + "Bookings" + ], + "responses": { + "200": { + "description": "Booking cancelled" + }, + "404": { + "description": "Booking not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Booking ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get booking by ID", + "tags": [ + "Bookings" + ], + "responses": { + "200": { + "description": "Booking details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed", + "cancelled" + ] + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "event_id": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "reminder_sent": { + "type": "boolean" + }, + "confirmed_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + }, + "404": { + "description": "Booking not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Booking ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Confirm or decline a booking (owner)", + "tags": [ + "Bookings" + ], + "responses": { + "200": { + "description": "Booking updated" + }, + "400": { + "description": "Invalid action" + }, + "404": { + "description": "Booking not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Booking ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "confirm", + "decline" + ] + } + }, + "required": [ + "action" + ] + } + } + }, + "required": true + } + } + }, + "/v1/calendars": { + "get": { + "description": "List calendars of current user", + "tags": [ + "Calendars" + ], + "responses": { + "200": { + "description": "Array of calendars", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "frozen", + "deleted" + ] + }, + "type": { + "type": "string", + "enum": [ + "personal", + "commercial" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "category": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "owner_id": { + "type": "string" + }, + "short_name": { + "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" + } + }, + "confirmation": { + "type": "string", + "description": "auto, manual, or {timeout, N}" + }, + "rating_avg": { + "type": "number", + "format": "float" + }, + "rating_count": { + "type": "integer" + } + } + } + } + } + } + } + } + }, + "post": { + "description": "Create a new calendar", + "tags": [ + "Calendars" + ], + "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" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "personal", + "commercial" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "confirmation": { + "type": "string", + "description": "auto, manual, or {timeout, N}" + } + }, + "required": [ + "title" + ] + } + } + }, + "required": true + } + } + }, + "/v1/calendars/:calendar_id/events": { + "get": { + "description": "List events of a calendar with optional date range", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Array of events", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "location": { + "type": "object", + "nullable": true + }, + "duration": { + "type": "integer" + }, + "calendar_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "rating_avg": { + "type": "number", + "format": "float" + }, + "rating_count": { + "type": "integer" + }, + "event_type": { + "type": "string", + "enum": [ + "single", + "recurring" + ] + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "master_id": { + "type": "string", + "nullable": true + }, + "is_instance": { + "type": "boolean" + }, + "specialist_id": { + "type": "string", + "nullable": true + }, + "capacity": { + "type": "integer", + "nullable": true + }, + "online_link": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "edit_history": { + "type": "array", + "items": { + "type": "object" + }, + "nullable": true + }, + "recurrence": { + "type": "object", + "nullable": true + } + } + } + } + } + } + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Calendar not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "calendar_id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "from", + "description": "Start datetime (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": false + }, + { + "in": "query", + "name": "to", + "description": "End datetime (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": false + } + ] + }, + "post": { + "description": "Create a new event (single or recurring)", + "tags": [ + "Events" + ], + "responses": { + "201": { + "description": "Event created" + }, + "400": { + "description": "Missing required fields or invalid JSON" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Calendar not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "calendar_id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "location": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "lat": { + "type": "number", + "format": "float" + }, + "lon": { + "type": "number", + "format": "float" + } + } + }, + "duration": { + "type": "integer", + "description": "Duration in minutes" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "capacity": { + "type": "integer" + }, + "online_link": { + "type": "string" + }, + "recurrence": { + "type": "object", + "description": "Recurrence rule (RFC 5545)" + } + }, + "required": [ + "title", + "start_time", + "duration" + ] + } + } + }, + "required": true + } + } + }, + "/v1/calendars/:calendar_id/view": { + "get": { + "description": "Get calendar HTML view for a specific month", + "tags": [ + "Calendars" + ], + "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" + } + }, + "parameters": [ + { + "in": "path", + "name": "calendar_id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "month", + "description": "Month in YYYY-MM format", + "schema": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}$" + }, + "required": true + } + ] + } + }, + "/v1/calendars/:id": { + "delete": { + "description": "Delete calendar", + "tags": [ + "Calendars" + ], + "responses": { + "200": { + "description": "Calendar deleted" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Calendar not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get calendar by ID", + "tags": [ + "Calendars" + ], + "responses": { + "200": { + "description": "Calendar details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "frozen", + "deleted" + ] + }, + "type": { + "type": "string", + "enum": [ + "personal", + "commercial" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "category": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "owner_id": { + "type": "string" + }, + "short_name": { + "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" + } + }, + "confirmation": { + "type": "string", + "description": "auto, manual, or {timeout, N}" + }, + "rating_avg": { + "type": "number", + "format": "float" + }, + "rating_count": { + "type": "integer" + } + } + } + } + } + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Calendar not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update calendar", + "tags": [ + "Calendars" + ], + "responses": { + "200": { + "description": "Calendar updated" + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Calendar not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "personal", + "commercial" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "confirmation": { + "type": "string", + "description": "auto, manual, or {timeout, N}" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/events/:id": { + "delete": { + "description": "Delete event", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Event deleted" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get event by ID", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Event details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "location": { + "type": "object", + "nullable": true + }, + "duration": { + "type": "integer" + }, + "calendar_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "rating_avg": { + "type": "number", + "format": "float" + }, + "rating_count": { + "type": "integer" + }, + "event_type": { + "type": "string", + "enum": [ + "single", + "recurring" + ] + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "master_id": { + "type": "string", + "nullable": true + }, + "is_instance": { + "type": "boolean" + }, + "specialist_id": { + "type": "string", + "nullable": true + }, + "capacity": { + "type": "integer", + "nullable": true + }, + "online_link": { + "type": "string", + "nullable": true + }, + "attachments": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "edit_history": { + "type": "array", + "items": { + "type": "object" + }, + "nullable": true + }, + "recurrence": { + "type": "object", + "nullable": true + } + } + } + } + } + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update event", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Event updated" + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "location": { + "type": "object", + "properties": { + "address": { + "type": "string" + }, + "lat": { + "type": "number", + "format": "float" + }, + "lon": { + "type": "number", + "format": "float" + } + } + }, + "duration": { + "type": "integer" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "specialist_id": { + "type": "string" + }, + "capacity": { + "type": "integer" + }, + "online_link": { + "type": "string" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/events/:id/bookings": { + "get": { + "description": "List bookings for an event (owner only)", + "tags": [ + "Bookings" + ], + "responses": { + "200": { + "description": "Array of bookings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed", + "cancelled" + ] + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "event_id": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "reminder_sent": { + "type": "boolean" + }, + "confirmed_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + } + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "post": { + "description": "Create a booking for an event", + "tags": [ + "Bookings" + ], + "responses": { + "201": { + "description": "Booking created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed", + "cancelled" + ] + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "event_id": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "reminder_sent": { + "type": "boolean" + }, + "confirmed_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + }, + "400": { + "description": "Event is full or not active" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + }, + "409": { + "description": "Already booked" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + } + }, + "/v1/events/:id/occurrences": { + "get": { + "description": "Get event occurrences in a date range", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Array of occurrences", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Event ID (only for materialized occurrences)" + }, + "status": { + "type": "string", + "enum": [ + "active", + "cancelled", + "completed" + ], + "description": "Status (only for materialized occurrences)" + }, + "duration": { + "type": "integer", + "description": "Duration in minutes (only for materialized occurrences)" + }, + "start_time": { + "type": "string", + "format": "date-time" + }, + "specialist_id": { + "type": "string", + "description": "Specialist ID (only for materialized occurrences)" + }, + "is_virtual": { + "type": "boolean" + } + } + } + } + } + } + }, + "400": { + "description": "Missing or invalid parameters" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "query", + "name": "from", + "description": "Start datetime (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": true + }, + { + "in": "query", + "name": "to", + "description": "End datetime (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": true + } + ] + } + }, + "/v1/events/:id/occurrences/:start_time": { + "delete": { + "description": "Cancel a specific occurrence", + "tags": [ + "Events" + ], + "responses": { + "200": { + "description": "Occurrence cancelled" + }, + "400": { + "description": "Missing or invalid parameters" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "start_time", + "description": "Start time of the occurrence (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + }, + "required": true + } + ] + } + }, + "/v1/health": { + "get": { + "description": "API health check", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "API is healthy", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "status": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/v1/login": { + "post": { + "description": "User login", + "tags": [ + "Auth" + ], + "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" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "format": "password" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email", + "password" + ] + } + } + }, + "required": true + } + } + }, + "/v1/refresh": { + "post": { + "description": "Refresh access token using refresh token", + "tags": [ + "Auth" + ], + "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" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "refresh_token": { + "type": "string" + } + }, + "required": [ + "refresh_token" + ] + } + } + }, + "required": true + } + } + }, + "/v1/register": { + "post": { + "description": "Register a new user", + "tags": [ + "Auth" + ], + "responses": { + "201": { + "description": "User registered, returns token and user info" + }, + "400": { + "description": "Missing email or password, or invalid JSON" + }, + "409": { + "description": "Email already exists" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "password": { + "type": "string", + "format": "password" + }, + "email": { + "type": "string", + "format": "email" + } + }, + "required": [ + "email", + "password" + ] + } + } + }, + "required": true + } + } + }, + "/v1/reports": { + "get": { + "description": "List reports (admin/moderator only)", + "tags": [ + "Reports" + ], + "responses": { + "200": { + "description": "Array of reports", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "reviewed", + "dismissed" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "event", + "calendar", + "review" + ] + }, + "target_id": { + "type": "string" + }, + "reporter_id": { + "type": "string" + }, + "resolved_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "resolved_by": { + "type": "string", + "nullable": true + } + } + } + } + } + } + }, + "403": { + "description": "Access denied (admin/mod only)" + } + }, + "parameters": [ + { + "in": "query", + "name": "target_type", + "description": "Filter by target type", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "target_id", + "description": "Filter by target ID", + "schema": { + "type": "string" + } + } + ] + }, + "post": { + "description": "Create a new report (complaint)", + "tags": [ + "Reports" + ], + "responses": { + "201": { + "description": "Report created" + }, + "400": { + "description": "Missing required fields or invalid JSON" + }, + "404": { + "description": "Target not found" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "reason": { + "type": "string" + }, + "target_type": { + "type": "string", + "enum": [ + "event", + "calendar" + ] + }, + "target_id": { + "type": "string" + } + }, + "required": [ + "target_type", + "target_id", + "reason" + ] + } + } + }, + "required": true + } + } + }, + "/v1/reviews": { + "get": { + "description": "List reviews for a target", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Array of reviews", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "visible", + "hidden", + "deleted" + ] + }, + "comment": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + } + } + } + } + } + } + }, + "400": { + "description": "Missing target_type or target_id" + } + }, + "parameters": [ + { + "in": "query", + "name": "target_type", + "description": "calendar or event", + "schema": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "required": true + }, + { + "in": "query", + "name": "target_id", + "description": "ID of the target", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "post": { + "description": "Create a new review", + "tags": [ + "Reviews" + ], + "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" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "target_type", + "target_id", + "rating", + "comment" + ] + } + } + }, + "required": true + } + } + }, + "/v1/reviews/:id": { + "delete": { + "description": "Delete review", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Review deleted" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Review not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "get": { + "description": "Get review by ID", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Review details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "visible", + "hidden", + "deleted" + ] + }, + "comment": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + } + } + } + } + } + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Review not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + }, + "put": { + "description": "Update review", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Review updated" + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Review not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "comment": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + } + } + } + } + }, + "required": true + } + } + }, + "/v1/search": { + "get": { + "description": "Search calendars and events", + "tags": [ + "Search" + ], + "responses": { + "200": { + "description": "Search results with pagination", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "offset": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "results": { + "type": "array", + "items": { + "type": "object" + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters" + }, + "500": { + "description": "Search failed" + } + }, + "parameters": [ + { + "in": "query", + "name": "type", + "description": "Type of entities to search", + "schema": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + } + }, + { + "in": "query", + "name": "q", + "description": "Search query", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "description": "Maximum results per page", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset for pagination", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "tags", + "description": "Comma-separated tags", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "sort", + "description": "Field to sort by", + "schema": { + "type": "string", + "enum": [ + "start_time", + "created_at", + "title" + ] + } + }, + { + "in": "query", + "name": "order", + "description": "Sort order", + "schema": { + "type": "string", + "enum": [ + "asc", + "desc" + ] + } + }, + { + "in": "query", + "name": "lat", + "description": "Latitude for geo search", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "in": "query", + "name": "lon", + "description": "Longitude for geo search", + "schema": { + "type": "number", + "format": "float" + } + }, + { + "in": "query", + "name": "radius", + "description": "Radius in km for geo search", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "from", + "description": "Start datetime (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "in": "query", + "name": "to", + "description": "End datetime (ISO8601)", + "schema": { + "type": "string", + "format": "date-time" + } + } + ] + } + }, + "/v1/subscription": { + "get": { + "description": "Get current user subscription", + "tags": [ + "Subscription" + ], + "responses": { + "200": { + "description": "Subscription details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "expired", + "cancelled" + ] + }, + "started_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "plan": { + "type": "string", + "enum": [ + "monthly", + "quarterly", + "biannual", + "annual" + ] + }, + "trial_used": { + "type": "boolean" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "description": "Activate subscription or start trial", + "tags": [ + "Subscription" + ], + "responses": { + "201": { + "description": "Subscription activated or trial started" + }, + "400": { + "description": "Invalid action or JSON" + }, + "402": { + "description": "Payment failed" + }, + "409": { + "description": "Already has active subscription" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "start_trial", + "activate" + ] + }, + "plan": { + "type": "string", + "enum": [ + "monthly", + "quarterly", + "biannual", + "annual" + ] + }, + "payment_info": { + "type": "object", + "description": "Payment information" + } + }, + "required": [ + "action" + ] + } + } + }, + "required": true + } + } + }, + "/v1/tickets": { + "get": { + "description": "List tickets (admin sees all, user sees own)", + "tags": [ + "Tickets" + ], + "responses": { + "200": { + "description": "Array of tickets", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ] + }, + "context": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "reporter_id": { + "type": "string" + }, + "error_hash": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "first_seen": { + "type": "string", + "format": "date-time" + }, + "last_seen": { + "type": "string", + "format": "date-time" + }, + "assigned_to": { + "type": "string", + "nullable": true + }, + "resolution_note": { + "type": "string", + "nullable": true + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ] + }, + "post": { + "description": "Create a new ticket (bug report)", + "tags": [ + "Tickets" + ], + "responses": { + "201": { + "description": "Ticket created" + }, + "400": { + "description": "Missing required fields or invalid JSON" + }, + "401": { + "description": "Unauthorized" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "context": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "error_message": { + "type": "string" + } + }, + "required": [ + "error_message" + ] + } + } + }, + "required": true + } + } + }, + "/v1/tickets/:id": { + "get": { + "description": "Get a user's own ticket by ID", + "tags": [ + "Tickets" + ], + "responses": { + "200": { + "description": "Ticket details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "open", + "in_progress", + "resolved", + "closed" + ] + }, + "context": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "reporter_id": { + "type": "string" + }, + "error_hash": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "first_seen": { + "type": "string", + "format": "date-time" + }, + "last_seen": { + "type": "string", + "format": "date-time" + }, + "assigned_to": { + "type": "string", + "nullable": true + }, + "resolution_note": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "403": { + "description": "Access denied (not the reporter)" + }, + "404": { + "description": "Ticket not found" + } + }, + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Ticket ID", + "schema": { + "type": "string" + }, + "required": true + } + ] + } + }, + "/v1/user/bookings": { + "get": { + "description": "List bookings of the current user", + "tags": [ + "Bookings" + ], + "responses": { + "200": { + "description": "Array of bookings", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed", + "cancelled" + ] + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "event_id": { + "type": "string" + }, + "notes": { + "type": "string", + "nullable": true + }, + "reminder_sent": { + "type": "boolean" + }, + "confirmed_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/user/me": { + "get": { + "description": "Get current user profile", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User profile", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "frozen", + "deleted" + ] + }, + "role": { + "type": "string", + "enum": [ + "user", + "bot" + ] + }, + "email": { + "type": "string", + "format": "email" + }, + "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" + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "User not found" + } + } + } + }, + "/v1/user/reviews": { + "get": { + "description": "List reviews of the current user", + "tags": [ + "Reviews" + ], + "responses": { + "200": { + "description": "Array of reviews", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "reason": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "visible", + "hidden", + "deleted" + ] + }, + "comment": { + "type": "string" + }, + "user_id": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + } + }, + "openapi": "3.0.3", + "servers": [ + { + "description": "API server", + "url": "http://localhost:8445" + } + ] +} \ No newline at end of file diff --git a/src/swagger/trails.erl b/src/swagger/trails.erl index 6696b6a..be1e9e8 100644 --- a/src/swagger/trails.erl +++ b/src/swagger/trails.erl @@ -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).