From 81728301e2c1b61718e49d852547274e289597d3 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Mon, 27 Jul 2026 21:17:15 +0300 Subject: [PATCH] fix: persist specialist_id on event create; 402 commercial without active sub. Fixes EventHub/EventHubBack#61 --- src/handlers/handler_calendars.erl | 26 ++++++++---------- src/handlers/handler_event_by_id.erl | 2 ++ src/handlers/handler_events.erl | 38 ++++++++++++++++++-------- src/logic/logic_calendar.erl | 2 +- src/logic/logic_subscription.erl | 27 ++++-------------- test/api/api_test_runner.erl | 17 ++++++++++++ test/unit/logic_calendar_tests.erl | 15 ++++++++++ test/unit/logic_event_tests.erl | 36 ++++++++++++++++++++++-- test/unit/logic_subscription_tests.erl | 6 ++-- 9 files changed, 115 insertions(+), 54 deletions(-) diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index 0432837..4076410 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -117,23 +117,20 @@ create_calendar(Req) -> Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)), Tags = maps:get(<<"tags">>, Decoded, []), Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)), - case Type of - commercial -> - case logic_subscription:can_create_commercial_calendar(UserId) of - true -> ok; - false -> - handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>), - throw(stop) - end; - personal -> ok - end, - case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of + case logic_calendar:create_calendar(UserId, Title, Description, Confirmation, Type) of {ok, Calendar} -> - Updates = [{tags, Tags}, {type, Type}], - core_calendar:update(Calendar#calendar.id, Updates), - {ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id), + Updated = case Tags of + [] -> Calendar; + _ -> + case logic_calendar:update_calendar(UserId, Calendar#calendar.id, [{tags, Tags}]) of + {ok, C2} -> C2; + _ -> Calendar + end + end, Response = calendar_to_json(Updated), handler_utils:send_json(Req2, 201, Response); + {error, subscription_required} -> + handler_utils:send_error(Req2, 402, <<"Subscription required for commercial calendar">>); {error, user_inactive} -> handler_utils:send_error(Req2, 403, <<"User account is not active">>); {error, {content_banned, _Words}} -> @@ -147,7 +144,6 @@ create_calendar(Req) -> _ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) catch - throw:stop -> ok; _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>) end; {error, Code, Message, Req1} -> diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl index f5435a2..44bdaa9 100644 --- a/src/handlers/handler_event_by_id.erl +++ b/src/handlers/handler_event_by_id.erl @@ -188,6 +188,8 @@ update_event(Req) -> handler_utils:send_error(Req2, 400, <<"Event cannot be in the past">>); {error, {content_banned, _}} -> handler_utils:send_error(Req2, 400, <<"Content contains banned words">>); + {error, invalid_specialist} -> + handler_utils:send_error(Req2, 400, <<"Invalid specialist_id for this calendar">>); {error, _} -> handler_utils:send_error(Req2, 500, <<"Internal server error">>) end; diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl index 5edbf6a..eaca7b3 100644 --- a/src/handlers/handler_events.erl +++ b/src/handlers/handler_events.erl @@ -140,6 +140,7 @@ event_create_schema() -> lon => #{type => number, format => float} } }, + specialist_id => #{type => string, nullable => true}, recurrence => #{type => object, description => <<"Recurrence rule (RFC 5545)">>} } }. @@ -176,10 +177,7 @@ create_event(Req) -> undefined -> case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration, Description) of {ok, Event} -> - update_event_fields(UserId, Event#event.id, Location, Decoded), - {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), - Response = handler_utils:event_to_json(UpdatedEvent), - handler_utils:send_json(Req2, 201, Response); + finish_create_event(Req2, UserId, Event, Location, Decoded); {error, access_denied} -> handler_utils:send_error(Req2, 403, <<"Access denied">>); {error, not_found} -> @@ -194,10 +192,7 @@ create_event(Req) -> RRule -> case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule, Description) of {ok, Event} -> - update_event_fields(UserId, Event#event.id, Location, Decoded), - {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), - Response = handler_utils:event_to_json(UpdatedEvent), - handler_utils:send_json(Req2, 201, Response); + finish_create_event(Req2, UserId, Event, Location, Decoded); {error, invalid_rrule} -> handler_utils:send_error(Req2, 400, <<"Invalid recurrence rule">>); {error, access_denied} -> @@ -262,6 +257,21 @@ list_events(Req) -> %%% Вспомогательные функции %%%=================================================================== +finish_create_event(Req, UserId, Event, Location, Decoded) -> + case update_event_fields(UserId, Event#event.id, Location, Decoded) of + ok -> + {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), + Response = handler_utils:event_to_json(UpdatedEvent), + handler_utils:send_json(Req, 201, Response); + {error, invalid_specialist} -> + _ = logic_event:delete_event(UserId, Event#event.id), + handler_utils:send_error(Req, 400, <<"Invalid specialist_id for this calendar">>); + {error, _} -> + {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), + Response = handler_utils:event_to_json(UpdatedEvent), + handler_utils:send_json(Req, 201, Response) + end. + update_event_fields(UserId, EventId, Location, Decoded) -> Updates = [], Updates1 = case Location of undefined -> Updates; _ -> [{location, Location} | Updates] end, @@ -269,12 +279,18 @@ update_event_fields(UserId, EventId, Location, Decoded) -> Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of undefined -> Updates2; Tags -> [{tags, Tags} | Updates2] end, %% description already applied in create_event/6 when present Updates4 = case maps:get(<<"online_link">>, Decoded, undefined) of undefined -> Updates3; Link -> [{online_link, Link} | Updates3] end, - case Updates4 of + Updates5 = case maps:get(<<"specialist_id">>, Decoded, undefined) of + undefined -> Updates4; + null -> [{specialist_id, <<>>} | Updates4]; + SpecId when is_binary(SpecId) -> [{specialist_id, SpecId} | Updates4]; + _ -> Updates4 + end, + case Updates5 of [] -> ok; _ -> - case logic_event:update_event(UserId, EventId, Updates4) of + case logic_event:update_event(UserId, EventId, Updates5) of {ok, _} -> ok; - _ -> ok + {error, _} = Err -> Err end end. diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index 3a8adee..ee595d3 100755 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -1,7 +1,7 @@ -module(logic_calendar). -include("records.hrl"). --export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1, +-export([create_calendar/3, create_calendar/4, create_calendar/5, get_calendar/2, list_calendars/1, update_calendar/3, delete_calendar/2, ensure_default_calendar/1]). -export([can_access/2, can_edit/2, booking_open/1]). -export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]). diff --git a/src/logic/logic_subscription.erl b/src/logic/logic_subscription.erl index 71f0198..1bd3aa9 100644 --- a/src/logic/logic_subscription.erl +++ b/src/logic/logic_subscription.erl @@ -8,7 +8,7 @@ %% ============ Управление подписками ============ -%% Начать пробный период (вызывается при первой попытке создать commercial календарь) +%% Начать пробный период (явно через POST /v1/subscription action=start_trial) start_trial(UserId) -> case core_subscription:get_active_by_user(UserId) of {ok, _} -> @@ -103,29 +103,12 @@ check_user_subscription(UserId) -> {ok, free, free} end. -%% Проверить, может ли пользователь создавать коммерческие календари -%% Если у пользователя нет активной подписки, но он ещё не использовал пробный период, -%% автоматически запускаем пробный период +%% Проверить, может ли пользователь создавать/апгрейдить commercial-календарь. +%% Только при уже активной подписке/trial; trial стартует явно через start_trial/1. can_create_commercial_calendar(UserId) -> case check_user_subscription(UserId) of - {ok, active, _} -> - true; - {ok, free, free} -> - % Пользователь без подписки - проверяем, не использовал ли он уже пробный период - {ok, AllSubs} = core_subscription:list_by_user(UserId), - TrialUsed = lists:any(fun(S) -> S#subscription.trial_used == true orelse S#subscription.plan == trial end, AllSubs), - case TrialUsed of - false -> - % Автоматически запускаем пробный период - case start_trial(UserId) of - {ok, _} -> true; - _ -> false - end; - true -> - false - end; - _ -> - false + {ok, active, _} -> true; + _ -> false end. %% ============ Обслуживание ============ diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index e0642b0..31bdf5c 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -409,9 +409,26 @@ register_and_login(Email, Password) -> -spec create_calendar(binary(), map()) -> binary(). create_calendar(Token, Params) -> + Type = maps:get(type, Params, maps:get(<<"type">>, Params, <<"personal">>)), + case Type of + <<"commercial">> -> ensure_commercial_subscription(Token); + commercial -> ensure_commercial_subscription(Token); + _ -> ok + end, #{<<"id">> := CalId} = client_post(<<"/v1/calendars">>, Token, Params), CalId. +-spec ensure_commercial_subscription(binary()) -> ok. +ensure_commercial_subscription(Token) -> + case client_request(post, <<"/v1/subscription">>, Token, + jsx:encode(#{action => <<"start_trial">>})) of + {ok, 201, _, _} -> ok; + {ok, 200, _, _} -> ok; + {ok, 409, _, _} -> ok; %% already has subscription / trial used with active + {ok, 400, _, _} -> ok; %% trial_already_used — may still have active paid + _ -> ok + end. + -spec create_event(binary(), binary(), map()) -> binary(). create_event(Token, CalId, Params) -> Path = <<"/v1/calendars/", CalId/binary, "/events">>, diff --git a/test/unit/logic_calendar_tests.erl b/test/unit/logic_calendar_tests.erl index f0754ab..b02751e 100755 --- a/test/unit/logic_calendar_tests.erl +++ b/test/unit/logic_calendar_tests.erl @@ -31,6 +31,7 @@ logic_calendar_test_() -> fun cleanup/1, [ {"Create calendar test", fun test_create_calendar/0}, + {"Create commercial requires subscription", fun test_create_commercial_gate/0}, {"Get calendar test", fun test_get_calendar/0}, {"List calendars test", fun test_list_calendars/0}, {"Update calendar test", fun test_update_calendar/0}, @@ -66,6 +67,18 @@ test_create_calendar() -> ?assertEqual(personal, Calendar#calendar.type), ?assertEqual(Confirmation, Calendar#calendar.confirmation). +test_create_commercial_gate() -> + UserId = create_test_user(), + ?assertMatch({error, subscription_required}, + logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial)), + {ok, Personal} = logic_calendar:create_calendar(UserId, <<"Personal">>, <<>>, manual, personal), + ?assertEqual(personal, Personal#calendar.type), + ?assertMatch({error, subscription_required}, + logic_calendar:update_calendar(UserId, Personal#calendar.id, [{type, commercial}])), + {ok, _} = logic_subscription:start_trial(UserId), + {ok, Commercial} = logic_calendar:create_calendar(UserId, <<"Studio">>, <<>>, manual, commercial), + ?assertEqual(commercial, Commercial#calendar.type). + test_get_calendar() -> UserId = create_test_user(), {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test">>, <<"">>, manual), @@ -92,6 +105,7 @@ test_list_calendars() -> test_update_calendar() -> UserId = create_test_user(), {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Original">>, <<"">>, manual), + {ok, _} = logic_subscription:start_trial(UserId), Updates = [{title, <<"Updated">>}, {type, commercial}, {confirmation, auto}], {ok, Updated} = logic_calendar:update_calendar(UserId, Calendar#calendar.id, Updates), @@ -122,6 +136,7 @@ test_access_control() -> ?assertNot(logic_calendar:can_edit(OtherId, PersonalCalendar)), ?assertNot(logic_calendar:can_access(OtherId, PersonalCalendar)), + {ok, _} = logic_subscription:start_trial(OwnerId), {ok, CommercialCalendar} = logic_calendar:update_calendar(OwnerId, PersonalCalendar#calendar.id, [{type, commercial}]), ?assert(logic_calendar:can_access(OtherId, CommercialCalendar)), ?assertNot(logic_calendar:can_edit(OtherId, CommercialCalendar)), diff --git a/test/unit/logic_event_tests.erl b/test/unit/logic_event_tests.erl index 3d4bf4f..be14f92 100644 --- a/test/unit/logic_event_tests.erl +++ b/test/unit/logic_event_tests.erl @@ -2,7 +2,7 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). --define(TABLES, [user, calendar, event]). +-define(TABLES, [user, calendar, event, calendar_specialist, subscription]). setup() -> eh_test_support:start_mnesia(), @@ -24,7 +24,8 @@ logic_event_test_() -> {"List events test", fun test_list_events/0}, {"Update event test", fun test_update_event/0}, {"Delete event test", fun test_delete_event/0}, - {"Event time validation test", fun test_time_validation/0} + {"Event time validation test", fun test_time_validation/0}, + {"Persist specialist_id on update", fun test_specialist_id_persist/0} ]}. create_test_user_and_calendar() -> @@ -43,6 +44,26 @@ create_test_user_and_calendar() -> {ok, Calendar} = logic_calendar:create_calendar(UserId, <<"Test Calendar">>, <<"">>, manual), {UserId, Calendar#calendar.id}. +create_commercial_with_specialist() -> + OwnerId = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}), + SpecId = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}), + Owner = #user{ + id = OwnerId, email = <<"owner@ex.com">>, password_hash = <<"hash">>, + role = user, status = active, + created_at = calendar:universal_time(), updated_at = calendar:universal_time() + }, + Spec = #user{ + id = SpecId, email = <<"spec@ex.com">>, password_hash = <<"hash">>, + role = user, status = active, + created_at = calendar:universal_time(), updated_at = calendar:universal_time() + }, + mnesia:dirty_write(Owner), + mnesia:dirty_write(Spec), + {ok, _} = logic_subscription:start_trial(OwnerId), + {ok, Cal} = logic_calendar:create_calendar(OwnerId, <<"Studio">>, <<>>, manual, commercial), + {ok, _} = core_calendar_specialist:create(Cal#calendar.id, SpecId, <<"Spec">>, []), + {OwnerId, SpecId, Cal#calendar.id}. + add_days(DateTime, Days) -> Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400, calendar:gregorian_seconds_to_datetime(Sec). @@ -102,3 +123,14 @@ test_time_validation() -> FutureTime = eh_test_support:future_start(), ?assertEqual(ok, logic_event:validate_event_time(FutureTime)). + +test_specialist_id_persist() -> + {OwnerId, SpecId, CalendarId} = create_commercial_with_specialist(), + StartTime = eh_test_support:future_start(), + {ok, Event} = logic_event:create_event(OwnerId, CalendarId, <<"Slot">>, StartTime, 60), + ?assertEqual(<<>>, Event#event.specialist_id), + {ok, Updated} = logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, SpecId}]), + ?assertEqual(SpecId, Updated#event.specialist_id), + BadSpec = base64:encode(crypto:strong_rand_bytes(12), #{padding => false}), + ?assertMatch({error, invalid_specialist}, + logic_event:update_event(OwnerId, Event#event.id, [{specialist_id, BadSpec}])). diff --git a/test/unit/logic_subscription_tests.erl b/test/unit/logic_subscription_tests.erl index 1900f19..7f87975 100644 --- a/test/unit/logic_subscription_tests.erl +++ b/test/unit/logic_subscription_tests.erl @@ -18,7 +18,7 @@ logic_subscription_test_() -> {"Check subscription - free", fun test_check_free/0}, {"Check subscription - trial", fun test_check_trial/0}, {"Check subscription - paid", fun test_check_paid/0}, - {"Can create commercial - free (auto-trial)", fun test_can_create_commercial_free/0}, + {"Can create commercial - free (no auto-trial)", fun test_can_create_commercial_free/0}, {"Can create commercial - trial", fun test_can_create_commercial_trial/0}, {"Can create commercial - paid", fun test_can_create_commercial_paid/0}, {"Get user subscription - free", fun test_get_user_subscription_free/0}, @@ -127,9 +127,9 @@ test_check_paid() -> {ok, active, monthly} = logic_subscription:check_user_subscription(UserId). test_can_create_commercial_free() -> - % Новый пользователь автоматически получает пробный период при проверке + % Без активной подписки/trial create commercial запрещён (trial — явно через start_trial) UserId = create_test_user(), - ?assert(logic_subscription:can_create_commercial_calendar(UserId)). + ?assertNot(logic_subscription:can_create_commercial_calendar(UserId)). test_can_create_commercial_trial() -> UserId = create_test_user(),