feat(booking): materialize occurrence on POST bookings. Refs EventHub/EventHubBack#67

This commit is contained in:
2026-08-14 10:52:24 +03:00
parent e3403f6ed6
commit 8a5f254c2a
5 changed files with 220 additions and 35 deletions
+76 -25
View File
@@ -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.
+35 -7
View File
@@ -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}) ->
+23 -1
View File
@@ -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