diff --git a/src/handlers/handler_bookings.erl b/src/handlers/handler_bookings.erl index 11d53af..8652b5a 100755 --- a/src/handlers/handler_bookings.erl +++ b/src/handlers/handler_bookings.erl @@ -45,12 +45,29 @@ trails() -> schema => #{type => string} } ], + requestBody => #{ + required => false, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + properties => #{ + occurrence_start => #{ + type => string, + format => <<"date-time">>, + description => <<"Required for recurring masters">> + } + } + } + } + } + }, responses => #{ 201 => #{ description => <<"Booking created">>, content => #{<<"application/json">> => #{schema => BookingSchema}} }, - 400 => #{description => <<"Event is full or not active">>}, + 400 => #{description => <<"Event is full, not active, or occurrence_start missing/invalid">>}, 403 => #{description => <<"Access denied">>}, 404 => #{description => <<"Event not found">>}, 409 => #{description => <<"Already booked">>} @@ -111,29 +128,39 @@ create_booking(Req) -> case handler_utils:auth_user(Req) of {ok, UserId, Req1} -> EventId = cowboy_req:binding(id, Req1), - case logic_booking:create_booking(UserId, EventId) of - {ok, Booking} -> - handler_utils:send_json(Req1, 201, booking_to_json(Booking)); - {error, already_booked} -> - handler_utils:send_error(Req1, 409, <<"Already booked">>); - {error, full} -> - handler_utils:send_error(Req1, 400, <<"Event is full">>); - {error, event_full} -> - handler_utils:send_error(Req1, 400, <<"Event is full">>); - {error, event_not_active} -> - handler_utils:send_error(Req1, 400, <<"Event is not active">>); - {error, personal_calendar} -> - handler_utils:send_error(Req1, 403, <<"personal_calendar">>); - {error, subscription_inactive} -> - handler_utils:send_error(Req1, 403, <<"subscription_inactive">>); - {error, own_event} -> - handler_utils:send_error(Req1, 403, <<"Cannot book own event">>); - {error, access_denied} -> - handler_utils:send_error(Req1, 403, <<"Access denied">>); - {error, not_found} -> - handler_utils:send_error(Req1, 404, <<"Event not found">>); - {error, _} -> - handler_utils:send_error(Req1, 500, <<"Internal server error">>) + {ok, Body, Req2} = cowboy_req:read_body(Req1), + case parse_occurrence_start(Body) of + {error, invalid_occurrence} -> + handler_utils:send_error(Req2, 400, <<"Invalid occurrence_start">>); + {ok, OccurrenceStart} -> + case logic_booking:create_booking(UserId, EventId, OccurrenceStart) of + {ok, Booking} -> + handler_utils:send_json(Req2, 201, booking_to_json(Booking)); + {error, already_booked} -> + handler_utils:send_error(Req2, 409, <<"Already booked">>); + {error, full} -> + handler_utils:send_error(Req2, 400, <<"Event is full">>); + {error, event_full} -> + handler_utils:send_error(Req2, 400, <<"Event is full">>); + {error, event_not_active} -> + handler_utils:send_error(Req2, 400, <<"Event is not active">>); + {error, occurrence_start_required} -> + handler_utils:send_error(Req2, 400, <<"occurrence_start required">>); + {error, invalid_occurrence} -> + handler_utils:send_error(Req2, 400, <<"Invalid occurrence_start">>); + {error, personal_calendar} -> + handler_utils:send_error(Req2, 403, <<"personal_calendar">>); + {error, subscription_inactive} -> + handler_utils:send_error(Req2, 403, <<"subscription_inactive">>); + {error, own_event} -> + handler_utils:send_error(Req2, 403, <<"Cannot book own event">>); + {error, access_denied} -> + handler_utils:send_error(Req2, 403, <<"Access denied">>); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Event not found">>); + {error, _} -> + handler_utils:send_error(Req2, 500, <<"Internal server error">>) + end end; {error, Code, Message, Req1} -> handler_utils:send_error(Req1, Code, Message) @@ -167,4 +194,28 @@ list_bookings(Req) -> %% @private Формирует JSON-представление записи #booking{}. -spec booking_to_json(#booking{}) -> map(). booking_to_json(Booking) -> - handler_utils:booking_to_json(Booking). \ No newline at end of file + handler_utils:booking_to_json(Booking). + +-spec parse_occurrence_start(binary()) -> + {ok, calendar:datetime() | undefined} | {error, invalid_occurrence}. +parse_occurrence_start(<<>>) -> + {ok, undefined}; +parse_occurrence_start(Body) -> + try jsx:decode(Body, [return_maps]) of + Map when is_map(Map) -> + case maps:get(<<"occurrence_start">>, Map, undefined) of + undefined -> + {ok, undefined}; + Iso when is_binary(Iso) -> + case handler_utils:parse_datetime(Iso) of + {ok, Dt} -> {ok, Dt}; + {error, _} -> {error, invalid_occurrence} + end; + _ -> + {error, invalid_occurrence} + end; + _ -> + {ok, undefined} + catch + _:_ -> {error, invalid_occurrence} + end. \ No newline at end of file diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index 3552802..965361f 100755 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -1,6 +1,6 @@ -module(logic_booking). -include("records.hrl"). --export([create_booking/2, confirm_booking/2, confirm_booking/3, +-export([create_booking/2, create_booking/3, confirm_booking/2, confirm_booking/3, cancel_booking/2, cancel_booking/3, get_booking/2, list_bookings/2, list_user_bookings/1, list_user_booking_requests/1, delete_booking/2, @@ -16,15 +16,30 @@ -spec create_booking(UserId :: binary(), EventId :: binary()) -> {ok, #booking{}} | {error, full | already_booked | not_found | personal_calendar | - subscription_inactive | own_event | event_not_active | access_denied}. + subscription_inactive | own_event | event_not_active | access_denied | + occurrence_start_required | invalid_occurrence}. create_booking(UserId, EventId) -> + create_booking(UserId, EventId, undefined). + +-spec create_booking(UserId :: binary(), EventId :: binary(), + OccurrenceStart :: calendar:datetime() | undefined) -> + {ok, #booking{}} | + {error, full | already_booked | not_found | personal_calendar | + subscription_inactive | own_event | event_not_active | access_denied | + occurrence_start_required | invalid_occurrence}. +create_booking(UserId, EventId, OccurrenceStart) -> case core_event:get_by_id(EventId) of {ok, #event{status = active} = Event} -> - case core_calendar:get_by_id(Event#event.calendar_id) of - {ok, Calendar} -> - create_on_calendar(UserId, Event, Calendar); - {error, not_found} -> - {error, not_found} + case resolve_bookable_event(Event, OccurrenceStart) of + {ok, BookEvent} -> + case core_calendar:get_by_id(BookEvent#event.calendar_id) of + {ok, Calendar} -> + create_on_calendar(UserId, BookEvent, Calendar); + {error, not_found} -> + {error, not_found} + end; + {error, _} = Err -> + Err end; {ok, _} -> {error, event_not_active}; @@ -32,6 +47,19 @@ create_booking(UserId, EventId) -> {error, not_found} end. +resolve_bookable_event(#event{event_type = recurring}, undefined) -> + {error, occurrence_start_required}; +resolve_bookable_event(#event{event_type = recurring} = Master, OccurrenceStart) -> + case logic_event:validate_occurrence(Master, OccurrenceStart) of + ok -> + logic_event:materialize_for_booking( + Master#event.id, OccurrenceStart, Master#event.specialist_id); + {error, _} = Err -> + Err + end; +resolve_bookable_event(Event, _OccurrenceStart) -> + {ok, Event}. + create_on_calendar(UserId, _Event, #calendar{owner_id = UserId}) -> {error, own_event}; create_on_calendar(_UserId, _Event, #calendar{type = personal}) -> diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl index 73267bb..0a9869a 100755 --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -4,7 +4,7 @@ -export([create_event/5, create_event/6, create_recurring_event/6, create_recurring_event/7, get_event/2, list_events/2, 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([materialize_for_booking/3, validate_occurrence/2]). -export([list_all_events/1, get_event_admin/1, update_event_admin/2, delete_event_admin/1]). -export([search_events/1]). @@ -147,6 +147,28 @@ cancel_occurrence(UserId, MasterId, OccurrenceStart) -> materialize_for_booking(MasterId, OccurrenceStart, SpecialistId) -> core_event:materialize_occurrence(MasterId, OccurrenceStart, SpecialistId). +%% Проверка, что OccurrenceStart — неотменённое вхождение серии. +validate_occurrence(#event{event_type = recurring} = Event, OccurrenceStart) -> + try + Decoded = jsx:decode(Event#event.recurrence_rule, [return_maps]), + RRuleMap = case Decoded of + Map when is_map(Map) -> Map; + _ -> #{} + end, + {ok, ParsedRule} = logic_recurrence:parse_rrule(RRuleMap), + Occurrences = logic_recurrence:generate_occurrences( + Event#event.start_time, ParsedRule, OccurrenceStart), + Valid = filter_cancelled(Occurrences, get_exceptions(Event#event.id)), + case lists:member(OccurrenceStart, Valid) of + true -> ok; + false -> {error, invalid_occurrence} + end + catch + _:_ -> {error, invalid_occurrence} + end; +validate_occurrence(_, _) -> + {error, not_recurring}. + %% Получение события с проверкой доступа get_event(UserId, EventId) -> case core_event:get_by_id(EventId) of diff --git a/test/api/users/user_bookings_tests.erl b/test/api/users/user_bookings_tests.erl index 6df58a8..3566e6f 100755 --- a/test/api/users/user_bookings_tests.erl +++ b/test/api/users/user_bookings_tests.erl @@ -47,6 +47,7 @@ test() -> test_cancel_booking(ParticipantToken, EventId), test_duplicate_booking(ParticipantToken, EventId), test_booking_unauthorized(EventId), + test_recurring_booking(OwnerToken, ParticipantToken), ct:pal("=== All user bookings tests passed ==="), ok. @@ -116,4 +117,30 @@ test_booking_unauthorized(EventId) -> Path = <<"/v1/events/", EventId/binary, "/bookings">>, Resp = api_test_runner:client_request(post, Path, <<>>, <<"{}">>), ?assertMatch({ok, 401, _, _}, Resp), - ct:pal(" OK: got 401"). \ No newline at end of file + ct:pal(" OK: got 401"). + +%% @doc Серия: без occurrence_start — 400; с датой вхождения — 201 на instance. +-spec test_recurring_booking(binary(), binary()) -> ok. +test_recurring_booking(OwnerToken, ParticipantToken) -> + ct:pal(" TEST: Recurring booking occurrence_start"), + CalId = api_test_runner:create_calendar(OwnerToken, #{ + title => <<"SeriesBooking">>, + type => <<"commercial">>, + confirmation => <<"auto">> + }), + Start = api_test_runner:future_date(), + StartIso = api_test_runner:iso8601_utc(Start), + #{<<"id">> := MasterId} = api_test_runner:client_post( + <<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken, + #{title => <<"Weekly slot">>, + start_time => StartIso, + duration => 60, + recurrence => #{freq => <<"WEEKLY">>, interval => 1}}), + Path = <<"/v1/events/", MasterId/binary, "/bookings">>, + {ok, 400, _, _} = api_test_runner:client_request(post, Path, ParticipantToken, <<"{}">>), + OccSec = calendar:datetime_to_gregorian_seconds(Start) + 7 * 86400, + OccIso = api_test_runner:iso8601_utc(calendar:gregorian_seconds_to_datetime(OccSec)), + #{<<"id">> := _Bid, <<"event_id">> := InstId} = + api_test_runner:client_post(Path, ParticipantToken, #{occurrence_start => OccIso}), + ?assertNotEqual(MasterId, InstId), + ct:pal(" OK: booked instance ~s", [InstId]). \ No newline at end of file diff --git a/test/unit/logic_booking_tests.erl b/test/unit/logic_booking_tests.erl index 1e31f67..918f256 100755 --- a/test/unit/logic_booking_tests.erl +++ b/test/unit/logic_booking_tests.erl @@ -2,7 +2,8 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). --define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_specialist]). +-define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_specialist, + recurrence_exception]). setup() -> eh_test_support:start_mnesia(), @@ -21,6 +22,10 @@ logic_booking_test_() -> [ {"Create booking auto confirms", fun test_create_booking_auto/0}, {"Create booking manual pending", fun test_create_booking_pending/0}, + {"Recurring booking requires occurrence_start", fun test_recurring_requires_occurrence/0}, + {"Recurring booking materializes occurrence", fun test_recurring_books_occurrence/0}, + {"Recurring booking rejects invalid occurrence", fun test_recurring_invalid_occurrence/0}, + {"Recurring booking rejects cancelled occurrence", fun test_recurring_cancelled_occurrence/0}, {"Create booking personal denied", fun test_booking_personal_denied/0}, {"Create duplicate booking", fun test_create_duplicate_booking/0}, {"Create booking for missing event", fun test_booking_missing_event/0}, @@ -96,6 +101,16 @@ past_start() -> Sec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) - 3600, calendar:gregorian_seconds_to_datetime(Sec). +add_days(DateTime, Days) -> + Sec = calendar:datetime_to_gregorian_seconds(DateTime) + Days * 86400, + calendar:gregorian_seconds_to_datetime(Sec). + +create_recurring_event(CalendarId) -> + StartTime = eh_test_support:future_start(), + RRule = #{<<"freq">> => <<"WEEKLY">>, <<"interval">> => 1}, + {ok, Event} = core_event:create_recurring(CalendarId, <<"Series">>, StartTime, 60, RRule), + {Event#event.id, StartTime}. + create_test_event_with_capacity(CalendarId, Capacity) -> StartTime = eh_test_support:future_start(), {ok, Event} = core_event:create(CalendarId, <<"Test Event">>, StartTime, 60), @@ -124,6 +139,48 @@ test_create_booking_pending() -> {ok, Stored} = core_booking:get_by_id(Booking#booking.id), ?assertEqual(pending, Stored#booking.status). +test_recurring_requires_occurrence() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, auto), + {MasterId, _} = create_recurring_event(CalendarId), + {error, occurrence_start_required} = logic_booking:create_booking(ParticipantId, MasterId). + +test_recurring_books_occurrence() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, auto), + {MasterId, StartTime} = create_recurring_event(CalendarId), + Occ = add_days(StartTime, 7), + {ok, Booking} = logic_booking:create_booking(ParticipantId, MasterId, Occ), + {ok, BookedEvent} = core_event:get_by_id(Booking#booking.event_id), + ?assertEqual(true, BookedEvent#event.is_instance), + ?assertEqual(MasterId, BookedEvent#event.master_id), + ?assertEqual(Occ, BookedEvent#event.start_time), + ?assertNotEqual(MasterId, Booking#booking.event_id). + +test_recurring_invalid_occurrence() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, auto), + {MasterId, StartTime} = create_recurring_event(CalendarId), + Bad = add_days(StartTime, 1), + {error, invalid_occurrence} = logic_booking:create_booking(ParticipantId, MasterId, Bad). + +test_recurring_cancelled_occurrence() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, auto), + {MasterId, StartTime} = create_recurring_event(CalendarId), + Occ = add_days(StartTime, 7), + mnesia:dirty_write(#recurrence_exception{ + master_id = MasterId, + original_start = Occ, + action = cancel, + new_start = undefined + }), + {error, invalid_occurrence} = logic_booking:create_booking(ParticipantId, MasterId, Occ). + test_booking_personal_denied() -> OwnerId = create_test_user(user), ParticipantId = create_test_user(user),