From faf251b11db04ca258f0659705a5b574944c2205 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Wed, 29 Jul 2026 15:00:55 +0300 Subject: [PATCH] fix(calendar): persist settings on PUT/POST Accept calendar.settings (e.g. week_patterns) in validate_update and convert_field; apply on create like tags. Unblocks Front#42 pattern save which previously got 200 with empty settings. --- src/handlers/handler_calendar_by_id.erl | 13 +++++++- src/handlers/handler_calendars.erl | 13 ++++++-- src/logic/logic_calendar.erl | 14 ++++++++ test/api/users/user_calendar_by_id_tests.erl | 21 ++++++++++++ test/unit/logic_calendar_tests.erl | 35 ++++++++++++++++++++ 5 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl index 794bfe9..52126e8 100755 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -85,6 +85,7 @@ calendar_schema() -> type => #{type => string, enum => [<<"personal">>, <<"commercial">>]}, confirmation => #{type => string, enum => [<<"auto">>, <<"manual">>]}, tags => #{type => array, items => #{type => string}}, + settings => #{type => object}, rating_avg => #{type => number, format => float}, rating_count => #{type => integer}, status => #{type => string}, @@ -102,7 +103,12 @@ calendar_update_schema() -> description => #{type => string}, type => #{type => string}, confirmation => #{type => string}, - tags => #{type => array, items => #{type => string}} + tags => #{type => array, items => #{type => string}}, + short_name => #{type => string}, + category => #{type => string}, + color => #{type => string}, + image_url => #{type => string}, + settings => #{type => object, nullable => true} } }. @@ -195,4 +201,9 @@ convert_field({<<"confirmation">>, #{<<"timeout">> := N}}) when is_integer(N), N {confirmation, {timeout, N}}; convert_field({<<"confirmation">>, Val}) -> {confirmation, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val}; +convert_field({<<"short_name">>, Val}) -> {short_name, Val}; +convert_field({<<"category">>, Val}) -> {category, Val}; +convert_field({<<"color">>, Val}) -> {color, Val}; +convert_field({<<"image_url">>, Val}) -> {image_url, Val}; +convert_field({<<"settings">>, Val}) when is_map(Val) -> {settings, Val}; convert_field(Other) -> Other. \ No newline at end of file diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index 4076410..96d457a 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -117,12 +117,21 @@ create_calendar(Req) -> Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)), Tags = maps:get(<<"tags">>, Decoded, []), Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)), + Settings = maps:get(<<"settings">>, Decoded, undefined), case logic_calendar:create_calendar(UserId, Title, Description, Confirmation, Type) of {ok, Calendar} -> - Updated = case Tags of + Extra0 = case Tags of + [] -> []; + _ -> [{tags, Tags}] + end, + Extra = case Settings of + S when is_map(S) -> [{settings, S} | Extra0]; + _ -> Extra0 + end, + Updated = case Extra of [] -> Calendar; _ -> - case logic_calendar:update_calendar(UserId, Calendar#calendar.id, [{tags, Tags}]) of + case logic_calendar:update_calendar(UserId, Calendar#calendar.id, Extra) of {ok, C2} -> C2; _ -> Calendar end diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index ee595d3..9851fbd 100755 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -242,6 +242,14 @@ normalize_update({type, <<"personal">>}) -> {type, personal}; normalize_update({type, <<"commercial">>}) -> {type, commercial}; normalize_update({type, personal}) -> {type, personal}; normalize_update({type, commercial}) -> {type, commercial}; +normalize_update({<<"settings">>, Value}) when is_map(Value) -> {settings, Value}; +normalize_update({<<"title">>, Value}) when is_binary(Value) -> {title, Value}; +normalize_update({<<"description">>, Value}) when is_binary(Value) -> {description, Value}; +normalize_update({<<"tags">>, Value}) when is_list(Value) -> {tags, Value}; +normalize_update({<<"short_name">>, Value}) when is_binary(Value) -> {short_name, Value}; +normalize_update({<<"category">>, Value}) when is_binary(Value) -> {category, Value}; +normalize_update({<<"color">>, Value}) when is_binary(Value) -> {color, Value}; +normalize_update({<<"image_url">>, Value}) when is_binary(Value) -> {image_url, Value}; normalize_update(Other) -> Other. validate_update({title, Value}) when is_binary(Value) -> true; @@ -249,6 +257,12 @@ validate_update({description, Value}) when is_binary(Value) -> true; validate_update({tags, Value}) when is_list(Value) -> true; validate_update({type, personal}) -> true; validate_update({type, commercial}) -> true; +validate_update({short_name, Value}) when is_binary(Value) -> true; +validate_update({category, Value}) when is_binary(Value) -> true; +validate_update({color, Value}) when is_binary(Value) -> true; +validate_update({image_url, Value}) when is_binary(Value) -> true; +%% Opaque JSON object for client prefs (e.g. week_patterns for schedule fill). +validate_update({settings, Value}) when is_map(Value) -> true; validate_update({confirmation, Value}) -> case Value of auto -> true; diff --git a/test/api/users/user_calendar_by_id_tests.erl b/test/api/users/user_calendar_by_id_tests.erl index f3aa268..bccfe17 100644 --- a/test/api/users/user_calendar_by_id_tests.erl +++ b/test/api/users/user_calendar_by_id_tests.erl @@ -23,6 +23,7 @@ test() -> test_get_calendar_unauthorized(CalId), test_get_calendar_not_found(Token), test_update_calendar(Token, CalId), + test_update_calendar_settings(Token, CalId), test_update_calendar_forbidden(OtherToken, CalId), test_delete_calendar(Token, CalId), test_delete_calendar_forbidden(OtherToken, CalId), @@ -60,6 +61,26 @@ test_update_calendar(Token, CalId) -> ?assertEqual(<<"New desc">>, maps:get(<<"description">>, Updated)), ct:pal(" OK"). +test_update_calendar_settings(Token, CalId) -> + ct:pal(" TEST: Persist calendar.settings.week_patterns"), + Path = <<"/v1/calendars/", CalId/binary>>, + Settings = #{ + <<"week_patterns">> => [ + #{ + <<"id">> => <<"pat1">>, + <<"name">> => <<"Mon-Fri">>, + <<"slots">> => [ + #{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>} + ] + } + ] + }, + Updated = api_test_runner:client_put(Path, Token, #{settings => Settings}), + ?assertEqual(Settings, maps:get(<<"settings">>, Updated)), + Got = api_test_runner:client_get(Path, Token), + ?assertEqual(Settings, maps:get(<<"settings">>, Got)), + ct:pal(" OK"). + test_update_calendar_forbidden(OtherToken, CalId) -> ct:pal(" TEST: Update calendar by non-owner (403)"), Path = <<"/v1/calendars/", CalId/binary>>, diff --git a/test/unit/logic_calendar_tests.erl b/test/unit/logic_calendar_tests.erl index b02751e..da36a1a 100755 --- a/test/unit/logic_calendar_tests.erl +++ b/test/unit/logic_calendar_tests.erl @@ -35,6 +35,8 @@ logic_calendar_test_() -> {"Get calendar test", fun test_get_calendar/0}, {"List calendars test", fun test_list_calendars/0}, {"Update calendar test", fun test_update_calendar/0}, + {"Persist calendar settings (week_patterns)", fun test_update_settings/0}, + {"Reject non-map settings", fun test_update_settings_invalid/0}, {"Delete calendar test", fun test_delete_calendar/0}, {"Access control test", fun test_access_control/0}, {"Ensure default calendar test", fun test_ensure_default_calendar/0} @@ -117,6 +119,39 @@ test_update_calendar() -> ?assertMatch({error, access_denied}, logic_calendar:update_calendar(OtherUserId, Calendar#calendar.id, Updates)). +test_update_settings() -> + UserId = create_test_user(), + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<"">>, manual), + Patterns = [ + #{ + <<"id">> => <<"p1">>, + <<"name">> => <<"Weekdays">>, + <<"updated_at">> => <<"2026-07-29T12:00:00Z">>, + <<"slots">> => [ + #{<<"weekday">> => 1, <<"time">> => <<"10:00">>, <<"duration">> => 60, <<"title">> => <<"Open">>} + ] + } + ], + Settings = #{<<"week_patterns">> => Patterns}, + {ok, Updated} = logic_calendar:update_calendar( + UserId, Calendar#calendar.id, [{settings, Settings}]), + ?assertEqual(Settings, Updated#calendar.settings), + {ok, Reloaded} = logic_calendar:get_calendar(UserId, Calendar#calendar.id), + ?assertEqual(Settings, Reloaded#calendar.settings), + %% binary key path (as from raw JSON list before convert_field) + Settings2 = Settings#{<<"foo">> => <<"bar">>}, + {ok, Updated2} = logic_calendar:update_calendar( + UserId, Calendar#calendar.id, [{<<"settings">>, Settings2}]), + ?assertEqual(Settings2, Updated2#calendar.settings). + +test_update_settings_invalid() -> + UserId = create_test_user(), + {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<"">>, manual), + %% non-map settings silently dropped by validate_updates — calendar unchanged + {ok, Same} = logic_calendar:update_calendar( + UserId, Calendar#calendar.id, [{settings, [<<"not-a-map">>]}]), + ?assertEqual(#{}, Same#calendar.settings). + test_delete_calendar() -> UserId = create_test_user(), {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual),