diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl new file mode 100644 index 0000000..fe5ded1 --- /dev/null +++ b/src/core/core_calendar.erl @@ -0,0 +1,85 @@ +-module(core_calendar). +-include("records.hrl"). + +-export([create/3, get_by_id/1, list_by_owner/1, update/2, delete/1]). +-export([generate_id/0]). + +%% Создание календаря +create(OwnerId, Title, Description) -> + Id = generate_id(), + Calendar = #calendar{ + id = Id, + owner_id = OwnerId, + title = Title, + description = Description, + tags = [], + type = personal, + confirmation = manual, + rating_avg = 0.0, + rating_count = 0, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + + F = fun() -> + mnesia:write(Calendar), + {ok, Calendar} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Получение календаря по ID +get_by_id(Id) -> + case mnesia:dirty_read(calendar, Id) of + [] -> {error, not_found}; + [Calendar] -> {ok, Calendar} + end. + +%% Список календарей владельца +list_by_owner(OwnerId) -> + Match = #calendar{owner_id = OwnerId, status = active, _ = '_'}, + Calendars = mnesia:dirty_match_object(Match), + {ok, Calendars}. + +%% Обновление календаря +update(Id, Updates) -> + F = fun() -> + case mnesia:read(calendar, Id) of + [] -> + {error, not_found}; + [Calendar] -> + UpdatedCalendar = apply_updates(Calendar, Updates), + mnesia:write(UpdatedCalendar), + {ok, UpdatedCalendar} + end + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Удаление календаря (soft delete) +delete(Id) -> + update(Id, [{status, deleted}]). + +%% Внутренние функции +generate_id() -> + base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). +apply_updates(Calendar, Updates) -> + Updated = lists:foldl(fun({Field, Value}, C) -> + set_field(Field, Value, C) + end, Calendar, Updates), + Updated#calendar{updated_at = calendar:universal_time()}. + +set_field(title, Value, C) -> C#calendar{title = Value}; +set_field(description, Value, C) -> C#calendar{description = Value}; +set_field(tags, Value, C) -> C#calendar{tags = Value}; +set_field(type, Value, C) -> C#calendar{type = Value}; +set_field(confirmation, Value, C) -> C#calendar{confirmation = Value}; +set_field(status, Value, C) -> C#calendar{status = Value}; +set_field(_, _, C) -> C. \ No newline at end of file diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 3e179ff..9be763e 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -81,8 +81,7 @@ delete(Id) -> %% Внутренние функции generate_id() -> - base64:encode(crypto:strong_rand_bytes(16)). - + base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). apply_updates(User, Updates) -> lists:foldl(fun({Field, Value}, U) -> set_field(Field, Value, U) end, User, Updates). diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 4261b9c..b5bad25 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -36,7 +36,9 @@ start_http() -> {"/v1/register", handler_register, []}, {"/v1/login", handler_login, []}, {"/v1/refresh", handler_refresh, []}, - {"/v1/user/me", handler_user_me, []} + {"/v1/user/me", handler_user_me, []}, + {"/v1/calendars", handler_calendars, []}, + {"/v1/calendars/:id", handler_calendar_by_id, []} ]} ]), diff --git a/src/handlers/handler_auth.erl b/src/handlers/handler_auth.erl new file mode 100644 index 0000000..bf749ae --- /dev/null +++ b/src/handlers/handler_auth.erl @@ -0,0 +1,19 @@ +-module(handler_auth). + +-export([authenticate/1]). + +authenticate(Req) -> + case cowboy_req:parse_header(<<"authorization">>, Req) of + {bearer, Token} -> + case logic_auth:verify_jwt(Token) of + {ok, Claims} -> + UserId = maps:get(<<"user_id">>, Claims), + {ok, UserId, Req}; + {error, expired} -> + {error, 401, <<"Token expired">>, Req}; + {error, _} -> + {error, 401, <<"Invalid token">>, Req} + end; + _ -> + {error, 401, <<"Missing or invalid Authorization header">>, Req} + end. \ No newline at end of file diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl new file mode 100644 index 0000000..f0d8fba --- /dev/null +++ b/src/handlers/handler_calendar_by_id.erl @@ -0,0 +1,113 @@ +-module(handler_calendar_by_id). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_calendar(Req); + <<"PUT">> -> update_calendar(Req); + <<"DELETE">> -> delete_calendar(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/calendars/:id - получение календаря +get_calendar(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + CalendarId = cowboy_req:binding(id, Req1), + case logic_calendar:get_calendar(UserId, CalendarId) of + {ok, Calendar} -> + Response = calendar_to_json(Calendar), + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Calendar not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% PUT /v1/calendars/:id - обновление календаря +update_calendar(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + CalendarId = 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), + case logic_calendar:update_calendar(UserId, CalendarId, Updates) of + {ok, Calendar} -> + Response = calendar_to_json(Calendar), + send_json(Req2, 200, Response); + {error, access_denied} -> + send_error(Req2, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req2, 404, <<"Calendar not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% DELETE /v1/calendars/:id - удаление календаря +delete_calendar(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + CalendarId = cowboy_req:binding(id, Req1), + case logic_calendar:delete_calendar(UserId, CalendarId) of + {ok, _} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Calendar not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +calendar_to_json(Calendar) -> + #{ + id => Calendar#calendar.id, + owner_id => Calendar#calendar.owner_id, + title => Calendar#calendar.title, + description => Calendar#calendar.description, + tags => Calendar#calendar.tags, + type => Calendar#calendar.type, + confirmation => confirmation_to_json(Calendar#calendar.confirmation), + rating_avg => Calendar#calendar.rating_avg, + rating_count => Calendar#calendar.rating_count, + status => Calendar#calendar.status, + created_at => Calendar#calendar.created_at, + updated_at => Calendar#calendar.updated_at + }. + +confirmation_to_json(auto) -> <<"auto">>; +confirmation_to_json(manual) -> <<"manual">>; +confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl new file mode 100644 index 0000000..0a441ad --- /dev/null +++ b/src/handlers/handler_calendars.erl @@ -0,0 +1,90 @@ +-module(handler_calendars). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> create_calendar(Req); + <<"GET">> -> list_calendars(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% POST /v1/calendars - создание календаря +create_calendar(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + Decoded when is_map(Decoded) -> + case Decoded of + #{<<"title">> := Title} -> + Description = maps:get(<<"description">>, Decoded, <<"">>), + case logic_calendar:create_calendar(UserId, Title, Description) of + {ok, Calendar} -> + Response = calendar_to_json(Calendar), + send_json(Req2, 201, Response); + {error, user_inactive} -> + send_error(Req2, 403, <<"User account is not active">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing required field: title">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% GET /v1/calendars - список календарей +list_calendars(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + case logic_calendar:list_calendars(UserId) of + {ok, Calendars} -> + Response = [calendar_to_json(C) || C <- Calendars], + send_json(Req1, 200, Response); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +calendar_to_json(Calendar) -> + #{ + id => Calendar#calendar.id, + owner_id => Calendar#calendar.owner_id, + title => Calendar#calendar.title, + description => Calendar#calendar.description, + tags => Calendar#calendar.tags, + type => Calendar#calendar.type, + confirmation => confirmation_to_json(Calendar#calendar.confirmation), + rating_avg => Calendar#calendar.rating_avg, + rating_count => Calendar#calendar.rating_count, + status => Calendar#calendar.status, + created_at => Calendar#calendar.created_at, + updated_at => Calendar#calendar.updated_at + }. + +confirmation_to_json(auto) -> <<"auto">>; +confirmation_to_json(manual) -> <<"manual">>; +confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/logic/logic_auth.erl b/src/logic/logic_auth.erl index 1eba2fb..3133d5c 100644 --- a/src/logic/logic_auth.erl +++ b/src/logic/logic_auth.erl @@ -79,7 +79,7 @@ check_expiry(Claims) -> %% ============ Refresh Token ============ generate_refresh_token(_UserId) -> - Token = base64:encode(crypto:strong_rand_bytes(32)), + Token = base64:encode(crypto:strong_rand_bytes(32), #{mode => urlsafe, padding => false}), ExpiresAt = calendar:universal_time_to_local_time( calendar:gregorian_seconds_to_datetime( calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 86400 diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl new file mode 100644 index 0000000..c01e353 --- /dev/null +++ b/src/logic/logic_calendar.erl @@ -0,0 +1,91 @@ +-module(logic_calendar). +-include("records.hrl"). + +-export([create_calendar/3, get_calendar/2, list_calendars/1, + update_calendar/3, delete_calendar/2]). +-export([can_access/2, can_edit/2]). + +%% Создание календаря +create_calendar(UserId, Title, Description) -> + % Проверка, что пользователь может создавать календари + case core_user:get_by_id(UserId) of + {ok, User} -> + case User#user.status of + active -> + core_calendar:create(UserId, Title, Description); + _ -> + {error, user_inactive} + end; + {error, _} -> + {error, user_not_found} + end. + +%% Получение календаря с проверкой доступа +get_calendar(UserId, CalendarId) -> + case core_calendar:get_by_id(CalendarId) of + {ok, Calendar} -> + case can_access(UserId, Calendar) of + true -> {ok, Calendar}; + false -> {error, access_denied} + end; + Error -> + Error + end. + +%% Список календарей пользователя +list_calendars(UserId) -> + core_calendar:list_by_owner(UserId). + +%% Обновление календаря +update_calendar(UserId, CalendarId, Updates) -> + case core_calendar:get_by_id(CalendarId) of + {ok, Calendar} -> + case can_edit(UserId, Calendar) of + true -> + % Валидация обновлений + ValidUpdates = validate_updates(Updates), + core_calendar:update(CalendarId, ValidUpdates); + false -> + {error, access_denied} + end; + Error -> + Error + end. + +%% Удаление календаря +delete_calendar(UserId, CalendarId) -> + case core_calendar:get_by_id(CalendarId) of + {ok, Calendar} -> + case can_edit(UserId, Calendar) of + true -> + core_calendar:delete(CalendarId); + false -> + {error, access_denied} + end; + Error -> + Error + end. + +%% Проверка прав доступа +can_access(_UserId, #calendar{status = active}) -> true; +can_access(_UserId, _) -> false. + +can_edit(UserId, #calendar{owner_id = UserId, status = active}) -> true; +can_edit(_, _) -> false. + +%% Валидация полей обновления +validate_updates(Updates) -> + lists:filter(fun validate_update/1, Updates). + +validate_update({title, Value}) when is_binary(Value) -> true; +validate_update({description, Value}) when is_binary(Value) -> true; +validate_update({tags, Value}) when is_list(Value) -> true; +validate_update({type, Value}) when Value =:= personal; Value =:= commercial -> true; +validate_update({confirmation, Value}) -> + case Value of + auto -> true; + manual -> true; + {timeout, N} when is_integer(N), N > 0 -> true; + _ -> false + end; +validate_update(_) -> false. \ No newline at end of file