diff --git a/src/core/core_event.erl b/src/core/core_event.erl index f9e5fbe..631b23e 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -6,6 +6,7 @@ -export([generate_id/0]). -export([count_events/0, count_events_by_date/2]). -export([freeze/2, unfreeze/2]). +-export([list_all/0]). %% Создание одиночного события create(CalendarId, Title, StartTime, Duration) -> @@ -172,6 +173,10 @@ delete(Id) -> count_events() -> mnesia:table_info(event, size). +list_all() -> + Match = #event{status = active, is_instance = false, _ = '_'}, + mnesia:dirty_match_object(Match). + count_events_by_date(From, To) -> All = mnesia:dirty_match_object(#event{_ = '_'}), Filtered = lists:filter(fun(E) -> diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index 14b05fa..e27c5af 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -48,7 +48,7 @@ stats() -> %% ── новые функции ────────────────────────────────────── create_ticket(Data) -> - Id = base64:encode(crypto:strong_rand_bytes(9)), + Id = base64:encode(crypto:strong_rand_bytes(9), #{mode => urlsafe, padding => false}), Now = calendar:universal_time(), Ticket = #ticket{ id = Id, diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 5c73e24..ea6adbe 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -107,6 +107,9 @@ start_admin_http() -> % ================== ПОЛЬЗОВАТЕЛИ ================== {"/v1/admin/users", admin_handler_users, []}, {"/v1/admin/users/:id", admin_handler_user_by_id, []}, + % ================== СОБЫТИЯ ================== + {"/v1/admin/events", admin_handler_events, []}, + {"/v1/admin/events/:id", admin_handler_event_by_id, []}, % ================== ОТЧЁТЫ ================== {"/v1/admin/reports", admin_handler_reports, []}, {"/v1/admin/reports/:id", admin_handler_report_by_id, []}, diff --git a/src/handlers/admin/admin_handler_event_by_id.erl b/src/handlers/admin/admin_handler_event_by_id.erl new file mode 100644 index 0000000..2626eeb --- /dev/null +++ b/src/handlers/admin/admin_handler_event_by_id.erl @@ -0,0 +1,195 @@ +-module(admin_handler_event_by_id). +-behaviour(cowboy_handler). + +-export([init/2]). +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_event(Req); + <<"PUT">> -> update_event(Req); + <<"DELETE">> -> delete_event(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/admin/events/:id +get_event(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + case logic_event:get_event_admin(EventId) of + {ok, Event} -> + send_json(Req1, 200, event_to_json(Event)); + {error, not_found} -> + send_error(Req1, 404, <<"Event not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Msg, Req1} -> + send_error(Req1, Code, Msg) + end. + +%% PUT /v1/admin/events/:id +update_event(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + Updates = maps:to_list(UpdatesMap), + UpdatesWithTypes = convert_fields(Updates), + case logic_event:update_event_admin(EventId, UpdatesWithTypes) of + {ok, Event} -> + send_json(Req2, 200, event_to_json(Event)); + {error, not_found} -> + send_error(Req2, 404, <<"Event not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> send_error(Req1, 400, <<"Invalid JSON format">>) + end; + {error, Code, Msg, Req1} -> + send_error(Req1, Code, Msg) + end. + +%% DELETE /v1/admin/events/:id +delete_event(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + EventId = cowboy_req:binding(id, Req1), + case logic_event:delete_event_admin(EventId) of + {ok, _} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + send_error(Req1, 404, <<"Event not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Msg, Req1} -> + send_error(Req1, Code, Msg) + end. + +%% --- Вспомогательные функции (идентичны handler_event_by_id.erl) --- + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Msg, Req1} -> + {error, Code, Msg, Req1} + end. + +convert_fields(Updates) -> + lists:map(fun convert_field/1, Updates). + +convert_field({<<"title">>, Val}) -> {title, Val}; +convert_field({<<"description">>, Val}) -> {description, Val}; +convert_field({<<"event_type">>, Val}) -> {event_type, Val}; +convert_field({<<"start_time">>, Val}) -> + case parse_datetime(Val) of + {ok, Dt} -> {start_time, Dt}; + _ -> {start_time, Val} + end; +convert_field({<<"duration">>, Val}) -> {duration, Val}; +convert_field({<<"recurrence">>, Val}) -> + RuleJson = jsx:encode(Val), + {recurrence_rule, RuleJson}; +convert_field({<<"specialist_id">>, Val}) -> {specialist_id, Val}; +convert_field({<<"location">>, Val}) when is_map(Val) -> + Loc = #location{ + address = maps:get(<<"address">>, Val, undefined), + lat = maps:get(<<"lat">>, Val, undefined), + lon = maps:get(<<"lon">>, Val, undefined) + }, + {location, Loc}; +convert_field({<<"location">>, Val}) -> {location, Val}; +convert_field({<<"tags">>, Val}) -> {tags, Val}; +convert_field({<<"capacity">>, Val}) -> {capacity, Val}; +convert_field({<<"online_link">>, Val}) -> {online_link, Val}; +convert_field({<<"status">>, Val}) -> {status, Val}; +convert_field(Other) -> Other. + +%% event_to_json, datetime_to_iso8601, parse_datetime, parse_datetime_binary +%% берутся те же, что и в admin_handler_events.erl (можно вынести в общий модуль, +%% но для простоты дублируем). +event_to_json(Event) -> + LocationJson = case Event#event.location of + undefined -> null; + #location{address = Addr, lat = Lat, lon = Lon} -> + #{address => Addr, lat => Lat, lon => Lon} + end, + RecurrenceJson = case Event#event.recurrence_rule of + undefined -> null; + Rule -> + try jsx:decode(Rule, [return_maps]) of + Map when is_map(Map) -> Map; + _ -> null + catch _:_ -> null + end + end, + #{ + id => Event#event.id, + calendar_id => Event#event.calendar_id, + title => Event#event.title, + description => Event#event.description, + event_type => Event#event.event_type, + start_time => datetime_to_iso8601(Event#event.start_time), + duration => Event#event.duration, + recurrence => RecurrenceJson, + master_id => Event#event.master_id, + is_instance => Event#event.is_instance, + specialist_id => Event#event.specialist_id, + location => LocationJson, + tags => Event#event.tags, + capacity => Event#event.capacity, + online_link => Event#event.online_link, + status => Event#event.status, + rating_avg => Event#event.rating_avg, + rating_count => Event#event.rating_count, + created_at => datetime_to_iso8601(Event#event.created_at), + updated_at => datetime_to_iso8601(Event#event.updated_at) + }. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary( + io_lib:format( + "~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second] + ) + ); +datetime_to_iso8601(undefined) -> + undefined. + +parse_datetime(Str) -> + try + [DateStr, TimeStr] = string:split(Str, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), + [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + Year = binary_to_integer(list_to_binary(YearStr)), + Month = binary_to_integer(list_to_binary(MonthStr)), + Day = binary_to_integer(list_to_binary(DayStr)), + Hour = binary_to_integer(list_to_binary(HourStr)), + Minute = binary_to_integer(list_to_binary(MinuteStr)), + Second = binary_to_integer(list_to_binary(SecondStr)), + {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} + catch _:_ -> {error, invalid_format} + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + Headers = #{<<"content-type">> => <<"application/json">>}, + cowboy_req:reply(Status, Headers, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_events.erl b/src/handlers/admin/admin_handler_events.erl new file mode 100644 index 0000000..04b3276 --- /dev/null +++ b/src/handlers/admin/admin_handler_events.erl @@ -0,0 +1,131 @@ +-module(admin_handler_events). +-behaviour(cowboy_handler). + +-export([init/2]). +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> + list_all_events(Req); + _ -> + send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/admin/events +list_all_events(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + Filters = parse_filters(Req1), + {ok, Events} = logic_event:list_all_events(Filters), + Json = [event_to_json(E) || E <- Events], + send_json(Req1, 200, Json); + {error, Code, Msg, Req1} -> + send_error(Req1, Code, Msg) + end. + +%% --- Вспомогательные функции --- + +parse_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + lists:filtermap( + fun + ({<<"from">>, Val}) -> {true, {from, parse_datetime_binary(Val)}}; + ({<<"to">>, Val}) -> {true, {to, parse_datetime_binary(Val)}}; + (_) -> false + end, + Qs + ). + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Msg, Req1} -> + {error, Code, Msg, Req1} + end. + +%% Сериализация события (полностью скопирована из handler_event_by_id.erl) +event_to_json(Event) -> + LocationJson = case Event#event.location of + undefined -> null; + #location{address = Addr, lat = Lat, lon = Lon} -> + #{address => Addr, lat => Lat, lon => Lon} + end, + RecurrenceJson = case Event#event.recurrence_rule of + undefined -> null; + Rule -> + try jsx:decode(Rule, [return_maps]) of + Map when is_map(Map) -> Map; + _ -> null + catch _:_ -> null + end + end, + #{ + id => Event#event.id, + calendar_id => Event#event.calendar_id, + title => Event#event.title, + description => Event#event.description, + event_type => Event#event.event_type, + start_time => datetime_to_iso8601(Event#event.start_time), + duration => Event#event.duration, + recurrence => RecurrenceJson, + master_id => Event#event.master_id, + is_instance => Event#event.is_instance, + specialist_id => Event#event.specialist_id, + location => LocationJson, + tags => Event#event.tags, + capacity => Event#event.capacity, + online_link => Event#event.online_link, + status => Event#event.status, + rating_avg => Event#event.rating_avg, + rating_count => Event#event.rating_count, + created_at => datetime_to_iso8601(Event#event.created_at), + updated_at => datetime_to_iso8601(Event#event.updated_at) + }. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary( + io_lib:format( + "~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second] + ) + ); +datetime_to_iso8601(undefined) -> + undefined. + +parse_datetime_binary(Str) -> + case parse_datetime(Str) of + {ok, Dt} -> Dt; + _ -> undefined + end. + +parse_datetime(Str) -> + try + [DateStr, TimeStr] = string:split(Str, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + [YearStr, MonthStr, DayStr] = string:split(DateStr, "-", all), + [HourStr, MinuteStr, SecondStr] = string:split(TimeStrNoZ, ":", all), + Year = binary_to_integer(list_to_binary(YearStr)), + Month = binary_to_integer(list_to_binary(MonthStr)), + Day = binary_to_integer(list_to_binary(DayStr)), + Hour = binary_to_integer(list_to_binary(HourStr)), + Minute = binary_to_integer(list_to_binary(MinuteStr)), + Second = binary_to_integer(list_to_binary(SecondStr)), + {ok, {{Year, Month, Day}, {Hour, Minute, Second}}} + catch _:_ -> {error, invalid_format} + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + Headers = #{<<"content-type">> => <<"application/json">>}, + cowboy_req:reply(Status, Headers, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl index 55947c0..82da62f 100644 --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -5,6 +5,7 @@ update_event/3, delete_event/2]). -export([validate_event_time/1, validate_event_time/2, get_occurrences/3, cancel_occurrence/3]). -export([materialize_for_booking/3]). +-export([list_all_events/1, get_event_admin/1, update_event_admin/2, delete_event_admin/1]). %% Создание одиночного события create_event(UserId, CalendarId, Title, StartTime, Duration) -> @@ -234,4 +235,42 @@ merge_materialized(MasterId, Occurrences) -> false -> {virtual, Occ}; Event -> {materialized, Event} end - end, Occurrences). \ No newline at end of file + end, Occurrences). + +%% ─── Административные функции (без проверки прав) ───────────────── + +list_all_events(Filters) -> + Events = core_event:list_all(), % возвращает список, а не {ok, List} + Filtered = apply_filters(Events, Filters), + {ok, Filtered}. + +get_event_admin(EventId) -> + core_event:get_by_id(EventId). + +update_event_admin(EventId, Updates) -> + case core_event:get_by_id(EventId) of + {ok, _Event} -> + ValidUpdates = validate_updates(Updates, undefined), + core_event:update(EventId, ValidUpdates); + Error -> + Error + end. + +delete_event_admin(EventId) -> + core_event:delete(EventId). + +%% Применяет фильтры from/to к списку событий +apply_filters(Events, []) -> + Events; +apply_filters(Events, [{from, From} | Rest]) -> + apply_filters( + [E || E <- Events, E#event.start_time >= From], + Rest + ); +apply_filters(Events, [{to, To} | Rest]) -> + apply_filters( + [E || E <- Events, E#event.start_time =< To], + Rest + ); +apply_filters(Events, [_ | Rest]) -> + apply_filters(Events, Rest). \ No newline at end of file diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl index 30b2dfc..0ed429c 100644 --- a/test/api/api_admin_tests.erl +++ b/test/api/api_admin_tests.erl @@ -109,6 +109,7 @@ test() -> }), {ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []), #{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]), + ct:pal(" OK (TicketId: ~p)~n", [TicketId]), ct:pal("OK~n"), %% TEST 13: Get ticket by ID @@ -260,5 +261,76 @@ test() -> {ok, {{_, 405, _}, _, _}} = httpc:request(post, {AdminURL ++ "/v1/admin/reviews", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", <<"{}">>}, [], []), ct:pal("OK~n"), + %% ======================================================== + %% Admin Events tests + %% ======================================================== + + FutureDate = api_SUITE:future_date(), + FutureDateStr = binary_to_list(FutureDate), + + %% TEST 28: List all events (GET /v1/admin/events) + ct:pal(" TEST 28: List all events... "), + {ok, {{_, 200, _}, _, EventsListResp}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + EventsList = jsx:decode(list_to_binary(EventsListResp), [return_maps]), + true = is_list(EventsList), + ct:pal(" OK (count: ~p)~n", [length(EventsList)]), + + %% TEST 29: List events with date filters + ct:pal(" TEST 29: List events with date filters... "), + FilterEventsURL = AdminURL ++ "/v1/admin/events?from=" ++ FutureDateStr ++ + "&to=" ++ FutureDateStr, + {ok, {{_, 200, _}, _, FilteredEventsResp}} = + httpc:request(get, {FilterEventsURL, + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + FilteredEventsList = jsx:decode(list_to_binary(FilteredEventsResp), [return_maps]), + true = is_list(FilteredEventsList), + ct:pal(" OK (filtered count: ~p)~n", [length(FilteredEventsList)]), + + %% TEST 30: Get event by ID (GET /v1/admin/events/:id) + ct:pal(" TEST 30: Get event by ID... "), + {ok, {{_, 200, _}, _, EventByIdResp}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(EventId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + #{<<"id">> := EventId} = jsx:decode(list_to_binary(EventByIdResp), [return_maps]), + ct:pal(" OK (id: ~s)~n", [binary_to_list(EventId)]), + + %% TEST 31: Update event by ID (PUT /v1/admin/events/:id) + ct:pal(" TEST 31: Update event by ID... "), + UpdateEventBody = jsx:encode(#{ + <<"title">> => <<"Updated by admin">>, + <<"description">> => <<"Admin test update">> + }), + {ok, {{_, 200, _}, _, UpdateEventResp}} = + httpc:request(put, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(EventId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", UpdateEventBody}, + [], []), + #{<<"title">> := <<"Updated by admin">>} = + jsx:decode(list_to_binary(UpdateEventResp), [return_maps]), + ct:pal(" OK~n"), + + %% TEST 32: Delete event by ID (DELETE /v1/admin/events/:id) + ct:pal(" TEST 32: Delete event by ID... "), + {ok, {{_, 200, _}, _, DeleteResp}} = + httpc:request(delete, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(EventId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + #{<<"status">> := <<"deleted">>} = jsx:decode(list_to_binary(DeleteResp), [return_maps]), + ct:pal(" OK (status deleted)~n"), + + %% TEST 33: Method not allowed (POST /v1/admin/events → 405) + ct:pal(" TEST 33: POST method not allowed... "), + {ok, {{_, 405, _}, _, _}} = + httpc:request(post, {AdminURL ++ "/v1/admin/events", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", <<"{}">>}, + [], []), + ct:pal("OK~n"), + ct:pal("~n✅ Admin API tests passed!~n"), {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index 2039bf1..7c6cb42 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -5,6 +5,7 @@ -export([unique_email/1, register_and_login/2, create_calendar/2, create_event/3]). -export([get_admin_token/0, get_admin_id/0, get_user_token/0, get_user_id/0, get_admin_url/0, get_base_url/0, get_admin_ws_url/0, get_base_ws_url/0]). -export([wait_for_server/0]). +-export([format_datetime/1]). -define(BASE_URL, base_url()). -define(ADMIN_URL, admin_base_url()). @@ -259,4 +260,10 @@ wait_for_server(Attempts) -> case httpc:request(get, {?BASE_URL ++ "/health", []}, ssl_opts(), [{timeout, 1000}]) of {ok, {{_, 200, _}, _, _}} -> ok; _ -> timer:sleep(1000), wait_for_server(Attempts - 1) - end. \ No newline at end of file + end. + +format_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary( + io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second]) + ). \ No newline at end of file diff --git a/test/api/api_tickets_tests.erl b/test/api/api_tickets_tests.erl index 5de79d6..6f28107 100644 --- a/test/api/api_tickets_tests.erl +++ b/test/api/api_tickets_tests.erl @@ -17,6 +17,7 @@ test() -> stacktrace => <<"Something broke">>}, Token), <<"id">>), + ct:pal(" OK (TicketId: ~p)~n", [TicketId]), io:format("OK~n"), %% TEST 2: Get my tickets (user)