diff --git a/include/records.hrl b/include/records.hrl index a3001cb..205b092 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -183,6 +183,16 @@ updated_at :: calendar:datetime() }). +%% Голос пользователя за отзыв (уникальность review_id+user_id — в транзакции) +-record(review_vote, { + id :: binary(), + review_id :: binary(), + user_id :: binary(), + value :: like | dislike, + created_at :: calendar:datetime(), + updated_at :: calendar:datetime() +}). + %% ------------------- Жалобы и модерация ------------------------------ -record(report, { id :: binary(), diff --git a/src/core/core_review.erl b/src/core/core_review.erl index abd654f..9b1eb39 100644 --- a/src/core/core_review.erl +++ b/src/core/core_review.erl @@ -111,8 +111,15 @@ update(Id, Updates) -> delete(Id) -> F = fun() -> case mnesia:read(review, Id) of - [] -> {error, not_found}; - [Review] -> mnesia:delete_object(Review), {ok, deleted} + [] -> + {error, not_found}; + [Review] -> + Votes = mnesia:match_object(#review_vote{review_id = Id, _ = '_'}), + lists:foreach(fun(#review_vote{id = VoteId}) -> + mnesia:delete({review_vote, VoteId}) + end, Votes), + mnesia:delete_object(Review), + {ok, deleted} end end, case mnesia:transaction(F) of diff --git a/src/core/core_review_vote.erl b/src/core/core_review_vote.erl new file mode 100644 index 0000000..c8bb9a5 --- /dev/null +++ b/src/core/core_review_vote.erl @@ -0,0 +1,156 @@ +%%%------------------------------------------------------------------- +%%% @doc Хранение голосов пользователей за отзывы (like/dislike). +%%% Счётчики на #review{} обновляются в той же транзакции. +%%% @end +%%%------------------------------------------------------------------- +-module(core_review_vote). +-include("records.hrl"). + +-export([upsert/3, delete/2, get_by_user_and_review/2, list_by_user/1, + list_by_review/1, delete_by_review/1]). + +%%%------------------------------------------------------------------- +%%% @doc Поставить или сменить голос. Идемпотентно при том же value. +%%% @end +%%%------------------------------------------------------------------- +-spec upsert(ReviewId :: binary(), UserId :: binary(), Value :: like | dislike) -> + {ok, #review{}, like | dislike} | {error, term()}. +upsert(ReviewId, UserId, Value) when Value =:= like; Value =:= dislike -> + Now = calendar:universal_time(), + F = fun() -> + case mnesia:read(review, ReviewId) of + [] -> + {error, not_found}; + [#review{status = Status}] when Status =/= visible -> + {error, not_found}; + [#review{user_id = AuthorId}] when AuthorId =:= UserId -> + {error, own_review}; + [Review] -> + case find_vote(ReviewId, UserId) of + [] -> + Vote = #review_vote{ + id = infra_utils:generate_id(16), + review_id = ReviewId, + user_id = UserId, + value = Value, + created_at = Now, + updated_at = Now + }, + mnesia:write(Vote), + Updated = bump(Review, Value, 1), + mnesia:write(Updated), + {ok, Updated, Value}; + [#review_vote{value = Value} = Vote] -> + %% тот же голос — идемпотентно + mnesia:write(Vote#review_vote{updated_at = Now}), + {ok, Review, Value}; + [#review_vote{value = OldValue} = Vote] -> + UpdatedVote = Vote#review_vote{value = Value, updated_at = Now}, + mnesia:write(UpdatedVote), + Updated0 = bump(Review, OldValue, -1), + Updated1 = bump(Updated0, Value, 1), + mnesia:write(Updated1), + {ok, Updated1, Value} + end + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%%%------------------------------------------------------------------- +%%% @doc Снять голос. Идемпотентно, если голоса нет. +%%% @end +%%%------------------------------------------------------------------- +-spec delete(ReviewId :: binary(), UserId :: binary()) -> + {ok, #review{}, null} | {error, term()}. +delete(ReviewId, UserId) -> + F = fun() -> + case mnesia:read(review, ReviewId) of + [] -> + {error, not_found}; + [#review{status = Status}] when Status =/= visible -> + {error, not_found}; + [#review{user_id = AuthorId}] when AuthorId =:= UserId -> + {error, own_review}; + [Review] -> + case find_vote(ReviewId, UserId) of + [] -> + {ok, Review, null}; + [#review_vote{value = Value, id = VoteId}] -> + mnesia:delete({review_vote, VoteId}), + Updated = bump(Review, Value, -1), + mnesia:write(Updated), + {ok, Updated, null} + end + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%%%------------------------------------------------------------------- +%%% @doc Голос пользователя за конкретный отзыв. +%%% @end +%%%------------------------------------------------------------------- +-spec get_by_user_and_review(UserId :: binary(), ReviewId :: binary()) -> + {ok, #review_vote{}} | {error, not_found}. +get_by_user_and_review(UserId, ReviewId) -> + case mnesia:dirty_match_object( + #review_vote{review_id = ReviewId, user_id = UserId, _ = '_'}) of + [] -> {error, not_found}; + [Vote] -> {ok, Vote}; + [Vote | _] -> {ok, Vote} + end. + +%%%------------------------------------------------------------------- +%%% @doc Все голоса пользователя. +%%% @end +%%%------------------------------------------------------------------- +-spec list_by_user(UserId :: binary()) -> [#review_vote{}]. +list_by_user(UserId) -> + mnesia:dirty_match_object(#review_vote{user_id = UserId, _ = '_'}). + +%%%------------------------------------------------------------------- +%%% @doc Все голоса за отзыв. +%%% @end +%%%------------------------------------------------------------------- +-spec list_by_review(ReviewId :: binary()) -> [#review_vote{}]. +list_by_review(ReviewId) -> + mnesia:dirty_match_object(#review_vote{review_id = ReviewId, _ = '_'}). + +%%%------------------------------------------------------------------- +%%% @doc Удалить все голоса отзыва (при физическом удалении review). +%%% Вызывать внутри транзакции или отдельно. +%%% @end +%%%------------------------------------------------------------------- +-spec delete_by_review(ReviewId :: binary()) -> ok. +delete_by_review(ReviewId) -> + F = fun() -> + lists:foreach(fun(#review_vote{id = Id}) -> + mnesia:delete({review_vote, Id}) + end, list_by_review_tx(ReviewId)), + ok + end, + case mnesia:transaction(F) of + {atomic, ok} -> ok; + {aborted, Reason} -> error({delete_votes_failed, Reason}) + end. + +%%%=================================================================== +%%% Внутренние +%%%=================================================================== + +find_vote(ReviewId, UserId) -> + mnesia:match_object(#review_vote{review_id = ReviewId, user_id = UserId, _ = '_'}). + +list_by_review_tx(ReviewId) -> + mnesia:match_object(#review_vote{review_id = ReviewId, _ = '_'}). + +-spec bump(#review{}, like | dislike, integer()) -> #review{}. +bump(#review{likes = L} = R, like, Delta) -> + R#review{likes = max(0, L + Delta), updated_at = calendar:universal_time()}; +bump(#review{dislikes = D} = R, dislike, Delta) -> + R#review{dislikes = max(0, D + Delta), updated_at = calendar:universal_time()}. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 7558122..e2248d8 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -102,6 +102,7 @@ start_http() -> {"/v1/bookings/:id", handler_booking_by_id, []}, {"/v1/reviews", handler_reviews, []}, {"/v1/reviews/:id", handler_review_by_id, []}, + {"/v1/reviews/:id/vote", handler_review_vote, []}, {"/v1/reports", handler_reports, []}, {"/v1/tickets", handler_tickets, []}, {"/v1/tickets/:id", handler_ticket_by_id, []}, diff --git a/src/handlers/handler_review_by_id.erl b/src/handlers/handler_review_by_id.erl index e2a8f1f..ebfc49d 100644 --- a/src/handlers/handler_review_by_id.erl +++ b/src/handlers/handler_review_by_id.erl @@ -92,6 +92,7 @@ review_schema() -> reason => #{type => string, nullable => true}, likes => #{type => integer}, dislikes => #{type => integer}, + my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true}, created_at => #{type => string, format => <<"date-time">>}, updated_at => #{type => string, format => <<"date-time">>} } @@ -128,7 +129,8 @@ get_review(Req) -> ReviewId = cowboy_req:binding(id, Req1), case logic_review:get_review(UserId, ReviewId) of {ok, Review} -> - handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review)); + MyVote = logic_review:my_vote(UserId, ReviewId), + handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review, MyVote)); {error, access_denied} -> handler_utils:send_error(Req1, 403, <<"Access denied">>); {error, not_found} -> @@ -154,7 +156,8 @@ update_review(Req) -> {ok, _} -> case core_review:get_by_id(ReviewId) of {ok, Updated} -> - handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated)); + MyVote = logic_review:my_vote(UserId, ReviewId), + handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated, MyVote)); _ -> handler_utils:send_error(Req2, 500, <<"Failed to retrieve updated review">>) end; diff --git a/src/handlers/handler_review_vote.erl b/src/handlers/handler_review_vote.erl new file mode 100644 index 0000000..e19abd8 --- /dev/null +++ b/src/handlers/handler_review_vote.erl @@ -0,0 +1,169 @@ +%%%------------------------------------------------------------------- +%%% @doc Голосование за отзыв (клиентский API). +%%% +%%% PUT /v1/reviews/:id/vote — поставить / сменить like|dislike +%%% DELETE /v1/reviews/:id/vote — снять голос +%%% @end +%%%------------------------------------------------------------------- +-module(handler_review_vote). +-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} + } + ], + VoteResponse = #{ + type => object, + properties => #{ + review_id => #{type => string}, + my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true}, + likes => #{type => integer, minimum => 0}, + dislikes => #{type => integer, minimum => 0} + } + }, + [ + #{ + path => <<"/v1/reviews/:id/vote">>, + method => <<"PUT">>, + description => <<"Put or change vote (like/dislike) on a review">>, + tags => [<<"Reviews">>], + parameters => BaseParams, + requestBody => #{ + required => true, + content => #{<<"application/json">> => #{schema => #{ + type => object, + required => [<<"value">>], + properties => #{ + value => #{type => string, enum => [<<"like">>, <<"dislike">>]} + } + }}} + }, + responses => #{ + 200 => #{ + description => <<"Vote applied">>, + content => #{<<"application/json">> => #{schema => VoteResponse}} + }, + 400 => #{description => <<"Invalid body">>}, + 401 => #{description => <<"Unauthorized">>}, + 403 => #{description => <<"Cannot vote on own review">>}, + 404 => #{description => <<"Review not found or unavailable">>} + } + }, + #{ + path => <<"/v1/reviews/:id/vote">>, + method => <<"DELETE">>, + description => <<"Remove vote from a review">>, + tags => [<<"Reviews">>], + parameters => BaseParams, + responses => #{ + 200 => #{ + description => <<"Vote removed">>, + content => #{<<"application/json">> => #{schema => VoteResponse}} + }, + 401 => #{description => <<"Unauthorized">>}, + 403 => #{description => <<"Cannot vote on own review">>}, + 404 => #{description => <<"Review not found or unavailable">>} + } + } + ]. + +%%%=================================================================== +%%% HTTP +%%%=================================================================== + +-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"PUT">> -> put_vote(Req); + <<"DELETE">> -> delete_vote(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +-spec put_vote(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. +put_vote(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + ReviewId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"value">> := ValueBin} -> + case parse_value(ValueBin) of + undefined -> + handler_utils:send_error(Req2, 400, <<"Invalid value: like or dislike required">>); + Value -> + case logic_review:put_vote(UserId, ReviewId, Value) of + {ok, Review, MyVote} -> + handler_utils:send_json(Req2, 200, vote_response(Review, MyVote)); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Review not found">>); + {error, own_review} -> + handler_utils:send_error(Req2, 403, <<"Cannot vote on own review">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end + end; + _ -> + handler_utils:send_error(Req2, 400, <<"Missing required field: value">>) + catch + _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. + +-spec delete_vote(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. +delete_vote(Req) -> + case handler_utils:auth_user(Req) of + {ok, UserId, Req1} -> + ReviewId = cowboy_req:binding(id, Req1), + case logic_review:delete_vote(UserId, ReviewId) of + {ok, Review, null} -> + handler_utils:send_json(Req1, 200, vote_response(Review, null)); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Review not found">>); + {error, own_review} -> + handler_utils:send_error(Req1, 403, <<"Cannot vote on own review">>); + {error, _} -> + handler_utils:send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + handler_utils:send_error(Req1, Code, Message) + end. + +%%%=================================================================== +%%% Helpers +%%%=================================================================== + +parse_value(<<"like">>) -> like; +parse_value(<<"dislike">>) -> dislike; +parse_value(_) -> undefined. + +vote_response(#review{id = Id, likes = Likes, dislikes = Dislikes}, MyVote) -> + #{ + review_id => Id, + my_vote => vote_json(MyVote), + likes => Likes, + dislikes => Dislikes + }. + +vote_json(like) -> <<"like">>; +vote_json(dislike) -> <<"dislike">>; +vote_json(null) -> null. \ No newline at end of file diff --git a/src/handlers/handler_reviews.erl b/src/handlers/handler_reviews.erl index 1ec8b12..5529cf4 100644 --- a/src/handlers/handler_reviews.erl +++ b/src/handlers/handler_reviews.erl @@ -96,6 +96,7 @@ review_schema() -> reason => #{type => string, nullable => true}, likes => #{type => integer}, dislikes => #{type => integer}, + my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true}, created_at => #{type => string, format => <<"date-time">>}, updated_at => #{type => string, format => <<"date-time">>} } @@ -130,7 +131,7 @@ create_review(Req) -> TargetType = parse_target_type(TargetTypeBin), case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of {ok, Review} -> - Response = handler_utils:review_to_json(Review), + Response = handler_utils:review_to_json(Review, null), handler_utils:send_json(Req2, 201, Response); {error, already_reviewed} -> handler_utils:send_error(Req2, 409, <<"Already reviewed">>); @@ -171,7 +172,9 @@ list_reviews(Req) -> TargetType = parse_target_type(TargetTypeBin), case logic_review:list_reviews(UserId, TargetType, TargetId) of {ok, Reviews} -> - Response = [handler_utils:review_to_json(R) || R <- Reviews], + Votes = logic_review:my_votes_map(UserId), + Response = [handler_utils:review_to_json(R, maps:get(R#review.id, Votes, null)) + || R <- Reviews], handler_utils:send_json(Req1, 200, Response); {error, _} -> handler_utils:send_error(Req1, 500, <<"Internal server error">>) diff --git a/src/handlers/handler_user_reviews.erl b/src/handlers/handler_user_reviews.erl index d4bc8a7..adf63a6 100644 --- a/src/handlers/handler_user_reviews.erl +++ b/src/handlers/handler_user_reviews.erl @@ -52,6 +52,7 @@ review_schema() -> reason => #{type => string, nullable => true}, likes => #{type => integer}, dislikes => #{type => integer}, + my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true}, created_at => #{type => string, format => <<"date-time">>}, updated_at => #{type => string, format => <<"date-time">>} } @@ -76,7 +77,8 @@ list_user_reviews(Req) -> {ok, UserId, Req1} -> case logic_review:list_user_reviews(UserId) of {ok, Reviews} -> - Response = [handler_utils:review_to_json(R) || R <- Reviews], + %% Свои отзывы: голос за свой отзыв невозможен → my_vote обычно null + Response = [handler_utils:review_to_json(R, null) || R <- Reviews], handler_utils:send_json(Req1, 200, Response); {error, _} -> handler_utils:send_error(Req1, 500, <<"Internal server error">>) diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl index d40d161..6c43f6f 100644 --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -21,6 +21,7 @@ event_to_json/1, user_to_json/1, review_to_json/1, + review_to_json/2, report_to_json/1, ticket_to_json/1, calendar_to_json/1, @@ -359,9 +360,14 @@ user_to_json(User) -> updated_at => datetime_to_iso8601(User#user.updated_at) }. -%% @doc Преобразует #review{} в JSON-карту. +%% @doc Преобразует #review{} в JSON-карту (без my_vote). -spec review_to_json(#review{}) -> map(). review_to_json(Review) -> + review_to_json(Review, null). + +%% @doc Преобразует #review{} в JSON с голосом текущего пользователя. +-spec review_to_json(#review{}, like | dislike | null) -> map(). +review_to_json(Review, MyVote) -> #{ id => Review#review.id, user_id => Review#review.user_id, @@ -373,10 +379,15 @@ review_to_json(Review) -> reason => Review#review.reason, likes => Review#review.likes, dislikes => Review#review.dislikes, + my_vote => my_vote_json(MyVote), created_at => datetime_to_iso8601(Review#review.created_at), updated_at => datetime_to_iso8601(Review#review.updated_at) }. +my_vote_json(like) -> <<"like">>; +my_vote_json(dislike) -> <<"dislike">>; +my_vote_json(null) -> null. + %% @doc Преобразует #report{} в JSON-карту. -spec report_to_json(#report{}) -> map(). report_to_json(Report) -> diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index d86bd85..f7479f0 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -17,7 +17,7 @@ calendar, calendar_share, calendar_specialist, event, recurrence_exception, booking, - review, report, banned_word, automod_settings, automod_hit, + review, review_vote, report, banned_word, automod_settings, automod_hit, ticket, subscription, admin_audit, notification, stats_counter, stats_daily, node_metric, schema_migration @@ -322,6 +322,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}]; table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}]; table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}]; +table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}]; table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}]; table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}]; table_opts(automod_settings) -> [{disc_copies, [node()]}, {attributes, record_info(fields, automod_settings)}]; @@ -355,6 +356,8 @@ create_indices() -> mnesia:add_table_index(booking, event_id), mnesia:add_table_index(booking, user_id), mnesia:add_table_index(booking, status), + mnesia:add_table_index(review_vote, review_id), + mnesia:add_table_index(review_vote, user_id), mnesia:add_table_index(calendar, owner_id), mnesia:add_table_index(calendar, status), mnesia:add_table_index(calendar, short_name), diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index 4e87c73..059fc44 100644 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -23,7 +23,8 @@ '20260504150000_test_migration', '20260716230000_ticket_source_and_hash_index', '20260717180000_stats_counters', - '20260717190000_admin_stats_indexes' + '20260717190000_admin_stats_indexes', + '20260719210000_review_vote' ]). %% ------------------------------ diff --git a/src/logic/logic_review.erl b/src/logic/logic_review.erl index 3e12cae..b2ad569 100644 --- a/src/logic/logic_review.erl +++ b/src/logic/logic_review.erl @@ -6,6 +6,7 @@ -export([can_review/3, update_target_rating/2, can_moderate_review/2]). -export([list_admin_reviews/1, bulk_update_status/2]). -export([list_admin_reviews/2, get_review_admin/1, update_review_admin/2]). +-export([put_vote/3, delete_vote/2, my_vote/2, my_votes_map/1]). %% Создание отзыва create_review(UserId, TargetType, TargetId, Rating, Comment) -> @@ -68,6 +69,45 @@ list_reviews(UserId, TargetType, TargetId) -> list_user_reviews(UserId) -> core_review:list_by_user(UserId). +%%%------------------------------------------------------------------- +%%% @doc Поставить / сменить голос (like | dislike). +%%% @end +%%%------------------------------------------------------------------- +-spec put_vote(UserId :: binary(), ReviewId :: binary(), Value :: like | dislike) -> + {ok, #review{}, like | dislike} | {error, term()}. +put_vote(UserId, ReviewId, Value) when Value =:= like; Value =:= dislike -> + core_review_vote:upsert(ReviewId, UserId, Value). + +%%%------------------------------------------------------------------- +%%% @doc Снять голос с отзыва. +%%% @end +%%%------------------------------------------------------------------- +-spec delete_vote(UserId :: binary(), ReviewId :: binary()) -> + {ok, #review{}, null} | {error, term()}. +delete_vote(UserId, ReviewId) -> + core_review_vote:delete(ReviewId, UserId). + +%%%------------------------------------------------------------------- +%%% @doc Голос текущего пользователя за отзыв (`null` если нет). +%%% @end +%%%------------------------------------------------------------------- +-spec my_vote(UserId :: binary(), ReviewId :: binary()) -> like | dislike | null. +my_vote(UserId, ReviewId) -> + case core_review_vote:get_by_user_and_review(UserId, ReviewId) of + {ok, #review_vote{value = Value}} -> Value; + {error, not_found} -> null + end. + +%%%------------------------------------------------------------------- +%%% @doc Карта review_id => like | dislike для всех голосов пользователя. +%%% @end +%%%------------------------------------------------------------------- +-spec my_votes_map(UserId :: binary()) -> #{binary() => like | dislike}. +my_votes_map(UserId) -> + lists:foldl(fun(#review_vote{review_id = Rid, value = V}, Acc) -> + Acc#{Rid => V} + end, #{}, core_review_vote:list_by_user(UserId)). + %% Обновление отзыва (только автор) update_review(UserId, ReviewId, Updates) -> io:format("Updating review ~p with ~p~n", [ReviewId, Updates]), diff --git a/src/migrations/20260719210000_review_vote.erl b/src/migrations/20260719210000_review_vote.erl new file mode 100644 index 0000000..53c7abd --- /dev/null +++ b/src/migrations/20260719210000_review_vote.erl @@ -0,0 +1,37 @@ +%% @doc Create review_vote table and indexes if missing. +-module('20260719210000_review_vote'). + +-export([up/0, down/0]). + +-include("records.hrl"). + +up() -> + ensure_table(review_vote, record_info(fields, review_vote)), + ensure_index(review_vote, review_id), + ensure_index(review_vote, user_id), + ok. + +down() -> + _ = mnesia:delete_table(review_vote), + ok. + +ensure_table(Table, Attrs) -> + case lists:member(Table, mnesia:system_info(tables)) of + true -> + ok; + false -> + case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of + {atomic, ok} -> ok; + {aborted, {already_exists, Table}} -> ok; + {aborted, Reason} -> error({create_table_failed, Table, Reason}) + end + end. + +ensure_index(Table, Attr) -> + case mnesia:add_table_index(Table, Attr) of + {atomic, ok} -> ok; + {aborted, {already_exists, Table, _Pos}} -> ok; + {aborted, {already_exists, Table, Attr}} -> ok; + {aborted, {already_exists, _}} -> ok; + {aborted, Reason} -> error({add_index_failed, Table, Attr, Reason}) + end. diff --git a/src/swagger/client-swagger.json b/src/swagger/client-swagger.json index 784505f..5480db2 100644 --- a/src/swagger/client-swagger.json +++ b/src/swagger/client-swagger.json @@ -1,2752 +1,2924 @@ -{ - "info": { - "version": "1.0.0", - "title": "EventHub User API" - }, - "paths": { - "/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/auth/refresh": { - "post": { - "description": "Refresh user access token using refresh token", - "tags": [ - "Auth" - ], - "responses": { - "200": { - "description": "New user token pair (access + refresh)", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "token": { - "type": "string", - "description": "JWT access token" - }, - "refresh_token": { - "type": "string", - "description": "Refresh token" - } - } - } - } - } - }, - "400": { - "description": "Missing refresh_token field or invalid JSON" - }, - "401": { - "description": "Refresh token expired or not found" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "refresh_token" - ], - "properties": { - "refresh_token": { - "type": "string" - } - } - } - } - }, - "required": true - } - } - }, - "/v1/bookings/:id": { - "delete": { - "description": "Cancel booking (participant)", - "tags": [ - "Bookings" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Booking ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Booking cancelled" - }, - "404": { - "description": "Booking not found" - } - } - }, - "get": { - "description": "Get booking by ID", - "tags": [ - "Bookings" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Booking ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Booking details", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "pending", - "confirmed", - "cancelled" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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" - } - } - }, - "put": { - "description": "Confirm or decline a booking (owner)", - "tags": [ - "Bookings" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Booking ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Booking updated" - }, - "400": { - "description": "Invalid action" - }, - "404": { - "description": "Booking not found" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "action" - ], - "properties": { - "action": { - "type": "string", - "enum": [ - "confirm", - "decline" - ] - } - } - } - } - }, - "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", - "required": [ - "title" - ], - "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/calendars/:calendar_id/events": { - "get": { - "description": "List events of a calendar with optional date range", - "tags": [ - "Events" - ], - "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 - } - ], - "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" - }, - "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" - }, - "calendar_id": { - "type": "string" - }, - "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" - } - } - }, - "post": { - "description": "Create a new event (single or recurring)", - "tags": [ - "Events" - ], - "parameters": [ - { - "in": "path", - "name": "calendar_id", - "description": "Calendar ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "201": { - "description": "Event created" - }, - "400": { - "description": "Missing required fields or invalid JSON" - }, - "403": { - "description": "Access denied" - }, - "404": { - "description": "Calendar not found" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "title", - "start_time", - "duration" - ], - "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": true - } - } - }, - "/v1/calendars/:calendar_id/view": { - "get": { - "description": "Get calendar HTML view for a specific month", - "tags": [ - "Calendars" - ], - "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 - } - ], - "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" - } - } - } - }, - "/v1/calendars/:id": { - "delete": { - "description": "Delete calendar", - "tags": [ - "Calendars" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Calendar ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Calendar deleted" - } - } - }, - "get": { - "description": "Get calendar by ID", - "tags": [ - "Calendars" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Calendar ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Calendar details", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "personal", - "commercial" - ] - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "owner_id": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "confirmation": { - "type": "string", - "enum": [ - "auto", - "manual" - ] - }, - "rating_avg": { - "type": "number", - "format": "float" - }, - "rating_count": { - "type": "integer" - } - } - } - } - } - } - } - }, - "put": { - "description": "Update calendar", - "tags": [ - "Calendars" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Calendar ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Calendar updated" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "description": { - "type": "string" - }, - "title": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - } - }, - "confirmation": { - "type": "string" - } - } - } - } - }, - "required": true - } - } - }, - "/v1/events/:id": { - "delete": { - "description": "Delete event", - "tags": [ - "Events" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Event ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Event deleted" - }, - "403": { - "description": "Access denied" - }, - "404": { - "description": "Event not found" - } - } - }, - "get": { - "description": "Get event by ID", - "tags": [ - "Events" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Event ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "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" - }, - "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" - }, - "calendar_id": { - "type": "string" - }, - "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" - } - } - }, - "put": { - "description": "Update event", - "tags": [ - "Events" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Event ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Event updated" - }, - "400": { - "description": "Invalid request" - }, - "403": { - "description": "Access denied" - }, - "404": { - "description": "Event not found" - } - }, - "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" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Event ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "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" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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" - } - } - }, - "post": { - "description": "Create a booking for an event", - "tags": [ - "Bookings" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Event ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "201": { - "description": "Booking created", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "status": { - "type": "string", - "enum": [ - "pending", - "confirmed", - "cancelled" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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" - } - } - } - }, - "/v1/events/:id/occurrences": { - "get": { - "description": "Get event occurrences in a date range", - "tags": [ - "Events" - ], - "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 - } - ], - "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" - } - } - } - }, - "/v1/events/:id/occurrences/:start_time": { - "delete": { - "description": "Cancel a specific occurrence", - "tags": [ - "Events" - ], - "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 - } - ], - "responses": { - "200": { - "description": "Occurrence cancelled" - }, - "400": { - "description": "Missing or invalid parameters" - }, - "403": { - "description": "Access denied" - }, - "404": { - "description": "Event not found" - } - } - } - }, - "/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", - "required": [ - "email", - "password" - ], - "properties": { - "password": { - "type": "string", - "format": "password" - }, - "email": { - "type": "string", - "format": "email" - } - } - } - } - }, - "required": true - } - } - }, - "/v1/register": { - "post": { - "description": "Register a new user (account will be pending until email verified)", - "tags": [ - "Auth" - ], - "responses": { - "201": { - "description": "User registered successfully (pending verification)" - }, - "400": { - "description": "Missing email or password, or invalid JSON" - }, - "409": { - "description": "Email already exists" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "email", - "password" - ], - "properties": { - "password": { - "type": "string", - "format": "password" - }, - "email": { - "type": "string", - "format": "email" - } - } - } - } - }, - "required": true - } - } - }, - "/v1/reports": { - "get": { - "description": "List reports (admin/moderator only)", - "tags": [ - "Reports" - ], - "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" - } - } - ], - "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)" - } - } - }, - "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", - "required": [ - "target_type", - "target_id", - "reason" - ], - "properties": { - "reason": { - "type": "string" - }, - "target_type": { - "type": "string", - "enum": [ - "event", - "calendar" - ] - }, - "target_id": { - "type": "string" - } - } - } - } - }, - "required": true - } - } - }, - "/v1/reviews": { - "get": { - "description": "List reviews for a target", - "tags": [ - "Reviews" - ], - "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 - } - ], - "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" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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" - } - } - }, - "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", - "required": [ - "target_type", - "target_id", - "rating", - "comment" - ], - "properties": { - "comment": { - "type": "string" - }, - "target_type": { - "type": "string", - "enum": [ - "calendar", - "event" - ] - }, - "target_id": { - "type": "string" - }, - "rating": { - "maximum": 5, - "type": "integer", - "minimum": 1 - } - } - } - } - }, - "required": true - } - } - }, - "/v1/reviews/:id": { - "delete": { - "description": "Delete review", - "tags": [ - "Reviews" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Review ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Review deleted" - }, - "403": { - "description": "Access denied" - }, - "404": { - "description": "Review not found" - } - } - }, - "get": { - "description": "Get review by ID", - "tags": [ - "Reviews" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Review ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "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" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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" - } - } - }, - "put": { - "description": "Update review", - "tags": [ - "Reviews" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Review ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "responses": { - "200": { - "description": "Review updated" - }, - "400": { - "description": "Invalid request" - }, - "403": { - "description": "Access denied" - }, - "404": { - "description": "Review not found" - } - }, - "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" - ], - "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" - } - } - ], - "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" - } - } - } - }, - "/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" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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", - "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" - } - } - } - } - }, - "required": true - } - } - }, - "/v1/tickets": { - "get": { - "description": "List tickets (admin sees all, user sees own)", - "tags": [ - "Tickets" - ], - "parameters": [ - { - "in": "query", - "name": "limit", - "description": "Page size", - "schema": { - "type": "integer" - } - }, - { - "in": "query", - "name": "offset", - "description": "Offset", - "schema": { - "type": "integer" - } - } - ], - "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" - } - } - }, - "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", - "required": [ - "error_message" - ], - "properties": { - "context": { - "type": "string" - }, - "stacktrace": { - "type": "string" - }, - "error_message": { - "type": "string" - } - } - } - } - }, - "required": true - } - } - }, - "/v1/tickets/:id": { - "get": { - "description": "Get a user's own ticket by ID", - "tags": [ - "Tickets" - ], - "parameters": [ - { - "in": "path", - "name": "id", - "description": "Ticket ID", - "schema": { - "type": "string" - }, - "required": true - } - ], - "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" - } - } - } - }, - "/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" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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" - }, - "created_at": { - "type": "string", - "format": "date-time" - }, - "updated_at": { - "type": "string", - "format": "date-time" - }, - "user_id": { - "type": "string" - }, - "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" - } - } - } - }, - "/v1/verify": { - "post": { - "description": "Verify user account using email verification token", - "tags": [ - "Auth" - ], - "responses": { - "200": { - "description": "Account verified successfully" - }, - "400": { - "description": "Missing token field or invalid JSON" - }, - "404": { - "description": "Token not found" - }, - "410": { - "description": "Token expired" - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "token" - ], - "properties": { - "token": { - "type": "string", - "description": "Verification token received by email" - } - } - } - } - }, - "required": true - } - } - } - }, - "openapi": "3.0.3", - "servers": [ - { - "description": "API server", - "url": "http://localhost:8445" - } - ] -} \ No newline at end of file +{ + "info": { + "version": "1.0.0", + "title": "EventHub User API" + }, + "paths": { + "/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/auth/refresh": { + "post": { + "description": "Refresh user access token using refresh token", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "New user token pair (access + refresh)", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "JWT access token" + }, + "refresh_token": { + "type": "string", + "description": "Refresh token" + } + } + } + } + } + }, + "400": { + "description": "Missing refresh_token field or invalid JSON" + }, + "401": { + "description": "Refresh token expired or not found" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/bookings/:id": { + "delete": { + "description": "Cancel booking (participant)", + "tags": [ + "Bookings" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Booking ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Booking cancelled" + }, + "404": { + "description": "Booking not found" + } + } + }, + "get": { + "description": "Get booking by ID", + "tags": [ + "Bookings" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Booking ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Booking details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed", + "cancelled" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "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" + } + } + }, + "put": { + "description": "Confirm or decline a booking (owner)", + "tags": [ + "Bookings" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Booking ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Booking updated" + }, + "400": { + "description": "Invalid action" + }, + "404": { + "description": "Booking not found" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "action" + ], + "properties": { + "action": { + "type": "string", + "enum": [ + "confirm", + "decline" + ] + } + } + } + } + }, + "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", + "required": [ + "title" + ], + "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/calendars/:calendar_id/events": { + "get": { + "description": "List events of a calendar with optional date range", + "tags": [ + "Events" + ], + "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 + } + ], + "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" + }, + "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" + }, + "calendar_id": { + "type": "string" + }, + "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" + } + } + }, + "post": { + "description": "Create a new event (single or recurring)", + "tags": [ + "Events" + ], + "parameters": [ + { + "in": "path", + "name": "calendar_id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "Event created" + }, + "400": { + "description": "Missing required fields or invalid JSON" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Calendar not found" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title", + "start_time", + "duration" + ], + "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": true + } + } + }, + "/v1/calendars/:calendar_id/view": { + "get": { + "description": "Get calendar HTML view for a specific month", + "tags": [ + "Calendars" + ], + "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 + } + ], + "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" + } + } + } + }, + "/v1/calendars/:id": { + "delete": { + "description": "Delete calendar", + "tags": [ + "Calendars" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Calendar deleted" + } + } + }, + "get": { + "description": "Get calendar by ID", + "tags": [ + "Calendars" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Calendar details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "personal", + "commercial" + ] + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "owner_id": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "confirmation": { + "type": "string", + "enum": [ + "auto", + "manual" + ] + }, + "rating_avg": { + "type": "number", + "format": "float" + }, + "rating_count": { + "type": "integer" + } + } + } + } + } + } + } + }, + "put": { + "description": "Update calendar", + "tags": [ + "Calendars" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Calendar ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Calendar updated" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + }, + "confirmation": { + "type": "string" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/events/:id": { + "delete": { + "description": "Delete event", + "tags": [ + "Events" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Event deleted" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + } + }, + "get": { + "description": "Get event by ID", + "tags": [ + "Events" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "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" + }, + "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" + }, + "calendar_id": { + "type": "string" + }, + "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" + } + } + }, + "put": { + "description": "Update event", + "tags": [ + "Events" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Event updated" + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + }, + "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" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "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" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "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" + } + } + }, + "post": { + "description": "Create a booking for an event", + "tags": [ + "Bookings" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Event ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "201": { + "description": "Booking created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "confirmed", + "cancelled" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "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" + } + } + } + }, + "/v1/events/:id/occurrences": { + "get": { + "description": "Get event occurrences in a date range", + "tags": [ + "Events" + ], + "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 + } + ], + "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" + } + } + } + }, + "/v1/events/:id/occurrences/:start_time": { + "delete": { + "description": "Cancel a specific occurrence", + "tags": [ + "Events" + ], + "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 + } + ], + "responses": { + "200": { + "description": "Occurrence cancelled" + }, + "400": { + "description": "Missing or invalid parameters" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Event not found" + } + } + } + }, + "/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", + "required": [ + "email", + "password" + ], + "properties": { + "password": { + "type": "string", + "format": "password" + }, + "email": { + "type": "string", + "format": "email" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/register": { + "post": { + "description": "Register a new user (account will be pending until email verified)", + "tags": [ + "Auth" + ], + "responses": { + "201": { + "description": "User registered successfully (pending verification)" + }, + "400": { + "description": "Missing email or password, or invalid JSON" + }, + "409": { + "description": "Email already exists" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "password": { + "type": "string", + "format": "password" + }, + "email": { + "type": "string", + "format": "email" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/reports": { + "get": { + "description": "List reports (admin/moderator only)", + "tags": [ + "Reports" + ], + "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" + } + } + ], + "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)" + } + } + }, + "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", + "required": [ + "target_type", + "target_id", + "reason" + ], + "properties": { + "reason": { + "type": "string" + }, + "target_type": { + "type": "string", + "enum": [ + "event", + "calendar" + ] + }, + "target_id": { + "type": "string" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/reviews": { + "get": { + "description": "List reviews for a target", + "tags": [ + "Reviews" + ], + "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 + } + ], + "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" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + }, + "my_vote": { + "type": "string", + "enum": [ + "like", + "dislike" + ], + "nullable": true, + "description": "Current user vote; null if none" + } + } + } + } + } + } + }, + "400": { + "description": "Missing target_type or target_id" + } + } + }, + "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", + "required": [ + "target_type", + "target_id", + "rating", + "comment" + ], + "properties": { + "comment": { + "type": "string" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + } + } + } + } + }, + "required": true + } + } + }, + "/v1/reviews/:id": { + "delete": { + "description": "Delete review", + "tags": [ + "Reviews" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Review deleted" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Review not found" + } + } + }, + "get": { + "description": "Get review by ID", + "tags": [ + "Reviews" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "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" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + }, + "my_vote": { + "type": "string", + "enum": [ + "like", + "dislike" + ], + "nullable": true, + "description": "Current user vote; null if none" + } + } + } + } + } + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Review not found" + } + } + }, + "put": { + "description": "Update review", + "tags": [ + "Reviews" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Review updated" + }, + "400": { + "description": "Invalid request" + }, + "403": { + "description": "Access denied" + }, + "404": { + "description": "Review not found" + } + }, + "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" + ], + "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" + } + } + ], + "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" + } + } + } + }, + "/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" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "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", + "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" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/tickets": { + "get": { + "description": "List tickets (admin sees all, user sees own)", + "tags": [ + "Tickets" + ], + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Page size", + "schema": { + "type": "integer" + } + }, + { + "in": "query", + "name": "offset", + "description": "Offset", + "schema": { + "type": "integer" + } + } + ], + "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" + } + } + }, + "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", + "required": [ + "error_message" + ], + "properties": { + "context": { + "type": "string" + }, + "stacktrace": { + "type": "string" + }, + "error_message": { + "type": "string" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/tickets/:id": { + "get": { + "description": "Get a user's own ticket by ID", + "tags": [ + "Tickets" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Ticket ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "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" + } + } + } + }, + "/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" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "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" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "user_id": { + "type": "string" + }, + "target_type": { + "type": "string", + "enum": [ + "calendar", + "event" + ] + }, + "target_id": { + "type": "string" + }, + "rating": { + "maximum": 5, + "type": "integer", + "minimum": 1 + }, + "likes": { + "type": "integer" + }, + "dislikes": { + "type": "integer" + }, + "my_vote": { + "type": "string", + "enum": [ + "like", + "dislike" + ], + "nullable": true, + "description": "Current user vote; null if none" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + } + } + } + }, + "/v1/verify": { + "post": { + "description": "Verify user account using email verification token", + "tags": [ + "Auth" + ], + "responses": { + "200": { + "description": "Account verified successfully" + }, + "400": { + "description": "Missing token field or invalid JSON" + }, + "404": { + "description": "Token not found" + }, + "410": { + "description": "Token expired" + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "token" + ], + "properties": { + "token": { + "type": "string", + "description": "Verification token received by email" + } + } + } + } + }, + "required": true + } + } + }, + "/v1/reviews/:id/vote": { + "put": { + "description": "Put or change vote (like/dislike) on a review", + "tags": [ + "Reviews" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string", + "enum": [ + "like", + "dislike" + ] + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Vote applied", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "review_id": { + "type": "string" + }, + "my_vote": { + "type": "string", + "enum": [ + "like", + "dislike" + ], + "nullable": true + }, + "likes": { + "type": "integer", + "minimum": 0 + }, + "dislikes": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "400": { + "description": "Invalid body" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Cannot vote on own review" + }, + "404": { + "description": "Review not found or unavailable" + } + } + }, + "delete": { + "description": "Remove vote from a review", + "tags": [ + "Reviews" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "Review ID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Vote removed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "review_id": { + "type": "string" + }, + "my_vote": { + "type": "string", + "enum": [ + "like", + "dislike" + ], + "nullable": true + }, + "likes": { + "type": "integer", + "minimum": 0 + }, + "dislikes": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Cannot vote on own review" + }, + "404": { + "description": "Review not found or unavailable" + } + } + } + } + }, + "openapi": "3.0.3", + "servers": [ + { + "description": "API server", + "url": "http://localhost:8445" + } + ] +} diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 3775225..05095cd 100644 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -79,6 +79,7 @@ user() -> handler_events, handler_reports, handler_review_by_id, + handler_review_vote, handler_reviews, handler_search, handler_subscription, diff --git a/test/api/users/user_review_vote_tests.erl b/test/api/users/user_review_vote_tests.erl new file mode 100644 index 0000000..6ce890c --- /dev/null +++ b/test/api/users/user_review_vote_tests.erl @@ -0,0 +1,157 @@ +%%%------------------------------------------------------------------- +%%% @doc Тесты голосования за отзывы (like/dislike). +%%% +%%% PUT /v1/reviews/:id/vote +%%% DELETE /v1/reviews/:id/vote +%%% GET /v1/reviews/:id (my_vote) +%%% @end +%%%------------------------------------------------------------------- +-module(user_review_vote_tests). +-include_lib("eunit/include/eunit.hrl"). + +-export([test/0]). + +-spec test() -> ok. +test() -> + ct:pal("=== User Review Vote Tests ==="), + OwnerToken = api_test_runner:get_user_token(), + AuthorEmail = api_test_runner:unique_email(<<"rvauthor">>), + AuthorToken = api_test_runner:register_and_login(AuthorEmail, <<"pass">>), + VoterEmail = api_test_runner:unique_email(<<"rvvoter">>), + VoterToken = api_test_runner:register_and_login(VoterEmail, <<"pass">>), + OtherEmail = api_test_runner:unique_email(<<"rvother">>), + OtherToken = api_test_runner:register_and_login(OtherEmail, <<"pass">>), + + CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"VoteCal">>}), + #{<<"id">> := EventId} = api_test_runner:client_post( + <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, + #{title => <<"Event for votes">>, + start_time => api_test_runner:future_date_iso8601(), + duration => 60}), + #{<<"id">> := BookingId} = api_test_runner:client_post( + <<"/v1/events/", EventId/binary, "/bookings">>, AuthorToken, #{}), + api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, OwnerToken, + #{action => <<"confirm">>}), + #{<<"id">> := ReviewId} = api_test_runner:client_post( + <<"/v1/reviews">>, AuthorToken, + #{target_type => <<"event">>, + target_id => EventId, + rating => 5, + comment => <<"Nice event">>}), + + VotePath = <<"/v1/reviews/", ReviewId/binary, "/vote">>, + + test_put_like(VoterToken, VotePath), + test_put_like_idempotent(VoterToken, VotePath), + test_switch_to_dislike(VoterToken, VotePath), + test_my_vote_on_get(VoterToken, ReviewId), + test_my_vote_on_list(VoterToken, EventId, ReviewId), + test_delete_vote(VoterToken, VotePath), + test_delete_idempotent(VoterToken, VotePath), + test_own_review_forbidden(AuthorToken, VotePath), + test_unauthorized(VotePath), + test_not_found(VoterToken), + test_invalid_body(VoterToken, VotePath), + test_second_voter(OtherToken, VotePath), + + ct:pal("=== All user review vote tests passed ==="), + ok. + +%%%=================================================================== +%%% Cases +%%%=================================================================== + +test_put_like(Token, Path) -> + ct:pal(" TEST: PUT like"), + Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}), + ?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)), + ?assertEqual(1, maps:get(<<"likes">>, Resp)), + ?assertEqual(0, maps:get(<<"dislikes">>, Resp)), + ct:pal(" OK"). + +test_put_like_idempotent(Token, Path) -> + ct:pal(" TEST: PUT like idempotent"), + Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}), + ?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)), + ?assertEqual(1, maps:get(<<"likes">>, Resp)), + ?assertEqual(0, maps:get(<<"dislikes">>, Resp)), + ct:pal(" OK"). + +test_switch_to_dislike(Token, Path) -> + ct:pal(" TEST: switch like -> dislike"), + Resp = api_test_runner:client_put(Path, Token, #{value => <<"dislike">>}), + ?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, Resp)), + ?assertEqual(0, maps:get(<<"likes">>, Resp)), + ?assertEqual(1, maps:get(<<"dislikes">>, Resp)), + ct:pal(" OK"). + +test_my_vote_on_get(Token, ReviewId) -> + ct:pal(" TEST: GET review includes my_vote"), + Review = api_test_runner:client_get(<<"/v1/reviews/", ReviewId/binary>>, Token), + ?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, Review)), + ?assertEqual(1, maps:get(<<"dislikes">>, Review)), + ct:pal(" OK"). + +test_my_vote_on_list(Token, EventId, ReviewId) -> + ct:pal(" TEST: GET reviews list includes my_vote"), + Path = <<"/v1/reviews?target_type=event&target_id=", EventId/binary>>, + List = api_test_runner:client_get(Path, Token), + ?assert(is_list(List)), + Match = [R || R <- List, maps:get(<<"id">>, R) =:= ReviewId], + ?assertMatch([_], Match), + [R] = Match, + ?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, R)), + ct:pal(" OK"). + +test_delete_vote(Token, Path) -> + ct:pal(" TEST: DELETE vote"), + Resp = api_test_runner:client_delete(Path, Token), + ?assertEqual(null, maps:get(<<"my_vote">>, Resp)), + ?assertEqual(0, maps:get(<<"likes">>, Resp)), + ?assertEqual(0, maps:get(<<"dislikes">>, Resp)), + ct:pal(" OK"). + +test_delete_idempotent(Token, Path) -> + ct:pal(" TEST: DELETE vote idempotent"), + Resp = api_test_runner:client_delete(Path, Token), + ?assertEqual(null, maps:get(<<"my_vote">>, Resp)), + ?assertEqual(0, maps:get(<<"likes">>, Resp)), + ?assertEqual(0, maps:get(<<"dislikes">>, Resp)), + ct:pal(" OK"). + +test_own_review_forbidden(Token, Path) -> + ct:pal(" TEST: vote on own review -> 403"), + Body = jsx:encode(#{value => <<"like">>}), + Resp = api_test_runner:client_request(put, Path, Token, Body), + ?assertMatch({ok, 403, _, _}, Resp), + ct:pal(" OK"). + +test_unauthorized(Path) -> + ct:pal(" TEST: unauthorized -> 401"), + Body = jsx:encode(#{value => <<"like">>}), + Resp = api_test_runner:client_request(put, Path, <<>>, Body), + ?assertMatch({ok, 401, _, _}, Resp), + ct:pal(" OK"). + +test_not_found(Token) -> + ct:pal(" TEST: vote missing review -> 404"), + Path = <<"/v1/reviews/missing-review-id/vote">>, + Body = jsx:encode(#{value => <<"like">>}), + Resp = api_test_runner:client_request(put, Path, Token, Body), + ?assertMatch({ok, 404, _, _}, Resp), + ct:pal(" OK"). + +test_invalid_body(Token, Path) -> + ct:pal(" TEST: invalid value -> 400"), + Body = jsx:encode(#{value => <<"love">>}), + Resp = api_test_runner:client_request(put, Path, Token, Body), + ?assertMatch({ok, 400, _, _}, Resp), + ct:pal(" OK"). + +test_second_voter(Token, Path) -> + ct:pal(" TEST: second voter like"), + Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}), + ?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)), + ?assertEqual(1, maps:get(<<"likes">>, Resp)), + ?assertEqual(0, maps:get(<<"dislikes">>, Resp)), + ct:pal(" OK"). diff --git a/test/api_users_SUITE.erl b/test/api_users_SUITE.erl index 49503a0..196e46f 100644 --- a/test/api_users_SUITE.erl +++ b/test/api_users_SUITE.erl @@ -39,6 +39,7 @@ all() -> user_test_my_bookings, user_test_reviews, user_test_review_by_id, + user_test_review_vote, user_test_my_reviews, user_test_search, user_test_refresh, @@ -136,6 +137,9 @@ user_test_reviews(_Config) -> user_test_review_by_id(_Config) -> user_review_by_id_tests:test(). +user_test_review_vote(_Config) -> + user_review_vote_tests:test(). + user_test_my_reviews(_Config) -> user_my_reviews_tests:test().