feat(booking): materialize occurrence on POST bookings. Refs EventHub/EventHubBack#67
This commit is contained in:
@@ -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).
|
||||
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.
|
||||
@@ -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}) ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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").
|
||||
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]).
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user