feat(waitlist): optional event waitlist with FIFO promote.
CI / test (push) Successful in 7m12s
CI / deploy-ift (push) Successful in 2m44s
CI / e2e-ift (push) Successful in 1m28s
CI / deploy-stage (push) Successful in 2m3s
CI / e2e-stage (push) Successful in 1m24s

Commercial calendars opt in via settings.waitlist_enabled; join when full;
auto-promote on cancel/decline/expire with email and in-app notify.

Refs EventHub/EventHubBack#72
This commit is contained in:
2026-08-14 21:44:42 +03:00
parent 29111cd514
commit fb8869fa86
15 changed files with 730 additions and 9 deletions
+11 -1
View File
@@ -200,6 +200,16 @@
updated_at :: calendar:datetime()
}).
%% ------------------- Лист ожидания (commercial, settings.waitlist_enabled) ----
-record(waitlist_entry, {
id :: binary(),
event_id :: binary(),
user_id :: binary(),
status :: waiting | promoted | left,
created_at :: calendar:datetime(),
updated_at :: calendar:datetime()
}).
%% ------------------- Отзывы ------------------------------------------
-record(review, {
id :: binary(),
@@ -319,7 +329,7 @@
id :: binary(),
user_id :: binary(),
type :: booking_confirmed | event_reminder | event_cancelled |
specialist_invite | custom,
specialist_invite | waitlist_promoted | custom,
title :: binary(),
body :: binary(),
is_read :: boolean(),
+66
View File
@@ -0,0 +1,66 @@
-module(core_waitlist).
-include("records.hrl").
-export([create/2, get_by_id/1, update/2,
list_by_event/1, list_waiting_by_event/1,
get_waiting/2]).
-spec create(EventId :: binary(), UserId :: binary()) ->
{ok, #waitlist_entry{}} | {error, term()}.
create(EventId, UserId) ->
Id = infra_utils:generate_id(16),
Now = calendar:universal_time(),
Entry = #waitlist_entry{
id = Id,
event_id = EventId,
user_id = UserId,
status = waiting,
created_at = Now,
updated_at = Now
},
case mnesia:dirty_write(Entry) of
ok -> {ok, Entry};
Error -> {error, Error}
end.
-spec get_by_id(binary()) -> {ok, #waitlist_entry{}} | {error, not_found}.
get_by_id(Id) ->
case mnesia:dirty_read(waitlist_entry, Id) of
[E] -> {ok, E};
[] -> {error, not_found}
end.
-spec update(binary(), [{atom(), term()}]) ->
{ok, #waitlist_entry{}} | {error, not_found | term()}.
update(Id, Updates) ->
case get_by_id(Id) of
{ok, E0} ->
E1 = lists:foldl(fun apply_upd/2, E0, Updates),
E2 = E1#waitlist_entry{updated_at = calendar:universal_time()},
case mnesia:dirty_write(E2) of
ok -> {ok, E2};
Err -> {error, Err}
end;
{error, _} = Err ->
Err
end.
apply_upd({status, V}, E) -> E#waitlist_entry{status = V};
apply_upd(_, E) -> E.
-spec list_by_event(binary()) -> [#waitlist_entry{}].
list_by_event(EventId) ->
mnesia:dirty_index_read(waitlist_entry, EventId, #waitlist_entry.event_id).
-spec list_waiting_by_event(binary()) -> [#waitlist_entry{}].
list_waiting_by_event(EventId) ->
[E || E <- list_by_event(EventId), E#waitlist_entry.status =:= waiting].
-spec get_waiting(EventId :: binary(), UserId :: binary()) ->
{ok, #waitlist_entry{}} | {error, not_found}.
get_waiting(EventId, UserId) ->
case [E || E <- list_by_event(EventId),
E#waitlist_entry.user_id =:= UserId,
E#waitlist_entry.status =:= waiting] of
[E | _] -> {ok, E};
[] -> {error, not_found}
end.
+1
View File
@@ -118,6 +118,7 @@ start_http() ->
{"/v1/events/:id/occurrences", handler_event_occurrences, []},
{"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []},
{"/v1/events/:id/bookings", handler_bookings, []},
{"/v1/events/:id/waitlist", handler_event_waitlist, []},
{"/v1/bookings/:id", handler_booking_by_id, []},
{"/v1/reviews", handler_reviews, []},
{"/v1/reviews/:id", handler_review_by_id, []},
+128
View File
@@ -0,0 +1,128 @@
%%%-------------------------------------------------------------------
%%% @doc Waitlist for events (Back#72).
%%% POST/DELETE/GET /v1/events/:id/waitlist
%%% @end
%%%-------------------------------------------------------------------
-module(handler_event_waitlist).
-behaviour(cowboy_handler).
-export([init/2, trails/0]).
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
init(Req, _Opts) ->
case cowboy_req:method(Req) of
<<"POST">> -> join(Req);
<<"DELETE">> -> leave(Req);
<<"GET">> -> get_status(Req);
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
end.
-spec trails() -> [map()].
trails() ->
[
#{
path => <<"/v1/events/:id/waitlist">>,
method => <<"POST">>,
description => <<"Join event waitlist (commercial, waitlist_enabled)">>,
tags => [<<"Waitlist">>],
responses => #{
200 => #{description => <<"Joined">>},
400 => #{description => <<"not_full / already_* / waitlist_disabled">>},
401 => #{description => <<"Unauthorized">>},
403 => #{description => <<"Forbidden">>},
404 => #{description => <<"Not found">>}
}
},
#{
path => <<"/v1/events/:id/waitlist">>,
method => <<"DELETE">>,
description => <<"Leave event waitlist">>,
tags => [<<"Waitlist">>],
responses => #{
200 => #{description => <<"Left">>},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"Not on waitlist">>}
}
},
#{
path => <<"/v1/events/:id/waitlist">>,
method => <<"GET">>,
description => <<"Own waitlist status, or full list for owner/specialist">>,
tags => [<<"Waitlist">>],
responses => #{
200 => #{description => <<"Status">>},
401 => #{description => <<"Unauthorized">>},
404 => #{description => <<"Not found">>}
}
}
].
join(Req) ->
EventId = cowboy_req:binding(id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_waitlist:join(UserId, EventId) of
{ok, Body} ->
handler_utils:send_json(Req1, 200, Body);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>);
{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, <<"own_event">>);
{error, waitlist_disabled} ->
handler_utils:send_error(Req1, 400, <<"waitlist_disabled">>);
{error, not_full} ->
handler_utils:send_error(Req1, 400, <<"not_full">>);
{error, already_booked} ->
handler_utils:send_error(Req1, 400, <<"already_booked">>);
{error, already_on_waitlist} ->
handler_utils:send_error(Req1, 400, <<"already_on_waitlist">>);
{error, event_not_active} ->
handler_utils:send_error(Req1, 400, <<"event_not_active">>);
{error, Reason} when is_atom(Reason) ->
handler_utils:send_error(Req1, 400, atom_to_binary(Reason, utf8));
{error, _} ->
handler_utils:send_error(Req1, 400, <<"Invalid request">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
leave(Req) ->
EventId = cowboy_req:binding(id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_waitlist:leave(UserId, EventId) of
ok ->
handler_utils:send_json(Req1, 200, #{ok => true});
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Not on waitlist">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
get_status(Req) ->
EventId = cowboy_req:binding(id, Req),
case handler_utils:auth_user(Req) of
{ok, UserId, Req1} ->
case logic_waitlist:list_for_manager(UserId, EventId) of
{ok, Body} ->
handler_utils:send_json(Req1, 200, Body);
{error, access_denied} ->
case logic_waitlist:status_for_user(UserId, EventId) of
{ok, Body} ->
handler_utils:send_json(Req1, 200, Body);
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>);
{error, _} ->
handler_utils:send_error(Req1, 403, <<"Forbidden">>)
end;
{error, not_found} ->
handler_utils:send_error(Req1, 404, <<"Event not found">>)
end;
{error, Code, Message, Req1} ->
handler_utils:send_error(Req1, Code, Message)
end.
+5 -1
View File
@@ -16,7 +16,7 @@
user, session, verification, password_reset, admin, admin_session, auth_session,
calendar, calendar_share, calendar_follow, calendar_specialist, specialist_invite,
event, recurrence_exception,
booking,
booking, waitlist_entry,
review, review_vote, report, banned_word, automod_settings, automod_hit,
ticket, subscription,
admin_audit, notification,
@@ -326,6 +326,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
table_opts(recurrence_exception) ->
[{disc_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
table_opts(waitlist_entry) -> [{disc_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
@@ -362,6 +363,9 @@ create_indices() ->
mnesia:add_table_index(booking, event_id),
mnesia:add_table_index(booking, user_id),
mnesia:add_table_index(booking, status),
mnesia:add_table_index(waitlist_entry, event_id),
mnesia:add_table_index(waitlist_entry, user_id),
mnesia:add_table_index(waitlist_entry, status),
mnesia:add_table_index(review_vote, review_id),
mnesia:add_table_index(review_vote, user_id),
mnesia:add_table_index(calendar, owner_id),
+34 -5
View File
@@ -8,7 +8,7 @@
list_bookings_admin/0, get_booking_admin/1,
list_event_bookings/1, list_event_bookings/2,
process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
cancel_pending_for_calendar/1]).
cancel_pending_for_calendar/1, can_manage_event_bookings/2]).
%%%-------------------------------------------------------------------
%%% @doc Создание бронирования с учётом commercial / confirmation / capacity.
@@ -133,7 +133,13 @@ confirm_booking(UserId, BookingId, decline) ->
true ->
case ensure_pending_actionable(Booking) of
{ok, _} ->
core_booking:update(BookingId, [{status, cancelled}]);
case core_booking:update(BookingId, [{status, cancelled}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
Ok;
Err ->
Err
end;
{error, Reason} ->
{error, Reason}
end;
@@ -155,7 +161,14 @@ cancel_booking(BookingId, UserId) ->
{ok, Booking};
_ ->
case Booking#booking.user_id =:= UserId of
true -> core_booking:update(BookingId, [{status, cancelled}]);
true ->
case core_booking:update(BookingId, [{status, cancelled}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(Booking#booking.event_id),
Ok;
Err ->
Err
end;
false -> {error, access_denied}
end
end;
@@ -462,7 +475,12 @@ maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Book
Now = calendar:universal_time(),
_ = core_booking:update(Id, [{status, confirmed}, {confirmed_at, Now}]);
false ->
_ = core_booking:update(Id, [{status, cancelled}])
case core_booking:update(Id, [{status, cancelled}]) of
{ok, _} ->
_ = logic_waitlist:maybe_promote(EventId);
_ ->
ok
end
end;
false ->
ok
@@ -553,7 +571,18 @@ ensure_not_past_pending(B) ->
-spec mark_expired(binary()) -> {ok, #booking{}} | {error, term()}.
mark_expired(BookingId) ->
core_booking:update(BookingId, [{status, expired}]).
case core_booking:get_by_id(BookingId) of
{ok, #booking{event_id = EventId}} ->
case core_booking:update(BookingId, [{status, expired}]) of
{ok, _} = Ok ->
_ = logic_waitlist:maybe_promote(EventId),
Ok;
Err ->
Err
end;
{error, _} = Err ->
Err
end.
-spec event_started(#event{}, non_neg_integer()) -> boolean().
event_started(#event{start_time = Start}, NowSec) ->
+4
View File
@@ -391,6 +391,10 @@ validate_settings_key(<<"default_duration_minutes">>, Val) ->
validate_default_duration(Val);
validate_settings_key(<<"default_recurrence">>, Val) ->
validate_default_recurrence(Val);
validate_settings_key(<<"waitlist_enabled">>, Val) when is_boolean(Val) ->
ok;
validate_settings_key(<<"waitlist_enabled">>, _) ->
{error, {invalid_settings, <<"waitlist_enabled">>}};
validate_settings_key(_Unknown, _Val) ->
ok.
+8 -1
View File
@@ -1,6 +1,6 @@
-module(logic_email).
-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
send_booking_reminder/4]).
send_booking_reminder/4, send_waitlist_promoted/3]).
%% SMTP via env (variant A / Back#68):
%% SMTP_HOST empty → log-only (local/tests)
@@ -29,6 +29,13 @@ send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
send(to_bin(Email), Subject, Plain, Html).
-spec send_waitlist_promoted(binary(), binary(), binary()) -> ok.
send_waitlist_promoted(Email, EventTitle, EventPath) ->
Url = abs_path(EventPath),
{Subject, Plain, Html} =
logic_email_templates:render_waitlist_promoted(Url, to_bin(EventTitle)),
send(to_bin(Email), Subject, Plain, Html).
send_kind(Kind, To0, Url) ->
{Subject, Plain, Html} = logic_email_templates:render(Kind, Url),
send(to_bin(To0), Subject, Plain, Html).
+15 -1
View File
@@ -1,5 +1,5 @@
-module(logic_email_templates).
-export([render/2, render_reminder/3]).
-export([render/2, render_reminder/3, render_waitlist_promoted/2]).
%% Kind: verify | invite | reset
-spec render(verify | invite | reset, binary()) ->
@@ -22,6 +22,20 @@ render_reminder(Url, Title, WhenText) ->
Pre = <<"Напоминание о записи в CalenTIQ">>,
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
-spec render_waitlist_promoted(binary(), binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render_waitlist_promoted(Url, Title) ->
Subject = <<"CalenTIQ: место в листе ожидания">>,
Heading = <<"Для вас освободилось место">>,
Lead = iolist_to_binary([
<<"Вы были в листе ожидания на «">>, Title,
<<"». Мы записали вас — откройте событие в CalenTIQ.">>
]),
Cta = <<"Открыть событие">>,
Foot = <<"Если запись вам не нужна, отмените её в приложении.">>,
Pre = <<"Место из листа ожидания CalenTIQ">>,
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre) ->
Plain = iolist_to_binary([Heading, <<"\n\n">>, Lead, <<"\n\n">>, Url, <<"\n">>]),
Html = fill(layout(), [
+7
View File
@@ -7,6 +7,7 @@
-export([notify_admin/2]).
-export([notify_specialist_invite/2]).
-export([notify_event_reminder/2]).
-export([notify_waitlist_promoted/2]).
%% Уведомление о бронировании
notify_booking(UserId, Booking) ->
@@ -53,6 +54,12 @@ notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) -
_ = core_notification:create(UserId, event_reminder, NTitle, Body),
broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
notify_waitlist_promoted(UserId, #{title := Title} = Data) ->
NTitle = <<"Место из листа ожидания">>,
Body = iolist_to_binary([<<"Вас записали на «">>, Title, <<"».">>]),
_ = core_notification:create(UserId, waitlist_promoted, NTitle, Body),
broadcast_to_user(UserId, waitlist_promoted, Data#{user_id => UserId}).
%% Уведомление для администраторов
notify_admin(Type, Data) ->
Message = {admin_notification, Type, Data},
+303
View File
@@ -0,0 +1,303 @@
%%%-------------------------------------------------------------------
%%% @doc Event waitlist (Back#72). Requires commercial calendar
%%% settings.waitlist_enabled = true. FIFO promote on free slot.
%%% @end
%%%-------------------------------------------------------------------
-module(logic_waitlist).
-include("records.hrl").
-export([join/2, leave/2, status_for_user/2, list_for_manager/2,
maybe_promote/1, waitlist_enabled/1]).
-spec waitlist_enabled(#calendar{}) -> boolean().
waitlist_enabled(#calendar{type = commercial, settings = Settings}) when is_map(Settings) ->
case maps:get(<<"waitlist_enabled">>, Settings, maps:get(waitlist_enabled, Settings, false)) of
true -> true;
<<"true">> -> true;
_ -> false
end;
waitlist_enabled(_) ->
false.
-spec join(UserId :: binary(), EventId :: binary()) ->
{ok, map()} |
{error, not_found | access_denied | waitlist_disabled | not_full |
already_booked | already_on_waitlist | personal_calendar |
subscription_inactive | event_not_active | own_event}.
join(UserId, EventId) ->
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, Cal} ->
join_checked(UserId, Event, Cal);
{error, not_found} ->
{error, not_found}
end;
{ok, _} ->
{error, event_not_active};
{error, not_found} ->
{error, not_found}
end.
join_checked(UserId, Event, Cal) ->
case Cal#calendar.type of
personal ->
{error, personal_calendar};
commercial ->
case logic_calendar:booking_open(Cal) of
false ->
{error, subscription_inactive};
true ->
case waitlist_enabled(Cal) of
false ->
{error, waitlist_disabled};
true ->
join_ready(UserId, Event, Cal)
end
end
end.
join_ready(UserId, #event{id = EventId, capacity = Cap} = Event, _Cal) ->
case owner_of_event(UserId, Event) of
true ->
{error, own_event};
false ->
case logic_booking_occupied(EventId, UserId) of
true ->
{error, already_booked};
false ->
case core_waitlist:get_waiting(EventId, UserId) of
{ok, _} ->
{error, already_on_waitlist};
{error, not_found} ->
case is_full(EventId, Cap) of
false ->
{error, not_full};
true ->
case core_waitlist:create(EventId, UserId) of
{ok, Entry} ->
{ok, entry_public(Entry, EventId)};
{error, _} = E ->
E
end
end
end
end
end.
owner_of_event(UserId, Event) ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, #calendar{owner_id = UserId}} -> true;
_ -> false
end.
-spec leave(UserId :: binary(), EventId :: binary()) ->
ok | {error, not_found}.
leave(UserId, EventId) ->
case core_waitlist:get_waiting(EventId, UserId) of
{ok, #waitlist_entry{id = Id}} ->
_ = core_waitlist:update(Id, [{status, left}]),
ok;
{error, not_found} ->
{error, not_found}
end.
-spec status_for_user(UserId :: binary(), EventId :: binary()) ->
{ok, map()} | {error, not_found | access_denied}.
status_for_user(UserId, EventId) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, Cal} ->
Enabled = waitlist_enabled(Cal),
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
case core_waitlist:get_waiting(EventId, UserId) of
{ok, Entry} ->
Pos = position_of(Entry#waitlist_entry.id, Waiting),
{ok, #{
enabled => Enabled,
joined => true,
position => Pos,
total => length(Waiting),
entry => entry_json(Entry)
}};
{error, not_found} ->
{ok, #{
enabled => Enabled,
joined => false,
position => null,
total => length(Waiting)
}}
end;
{error, not_found} ->
{error, not_found}
end;
{error, not_found} ->
{error, not_found}
end.
-spec list_for_manager(UserId :: binary(), EventId :: binary()) ->
{ok, map()} | {error, not_found | access_denied}.
list_for_manager(UserId, EventId) ->
case can_manage(UserId, EventId) of
true ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
{ok, Cal} = core_calendar:get_by_id(Event#event.calendar_id),
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
{ok, #{
enabled => waitlist_enabled(Cal),
total => length(Waiting),
items => [entry_json(E) || E <- Waiting]
}};
{error, not_found} ->
{error, not_found}
end;
false ->
{error, access_denied}
end.
%%%-------------------------------------------------------------------
%%% Promote first waiter when a booking slot frees.
%%%-------------------------------------------------------------------
-spec maybe_promote(EventId :: binary()) -> ok.
maybe_promote(EventId) ->
case core_event:get_by_id(EventId) of
{ok, #event{status = active, capacity = Cap, calendar_id = CalId} = Event} ->
case core_calendar:get_by_id(CalId) of
{ok, Cal} ->
case waitlist_enabled(Cal) andalso logic_calendar:booking_open(Cal)
andalso has_free_slot(EventId, Cap) of
true ->
promote_one(Event, Cal);
false ->
ok
end;
_ ->
ok
end;
_ ->
ok
end.
promote_one(Event, Cal) ->
case sort_waiting(core_waitlist:list_waiting_by_event(Event#event.id)) of
[] ->
ok;
[#waitlist_entry{id = Wid, user_id = UserId} = _Entry | _] ->
case logic_booking_occupied(Event#event.id, UserId) of
true ->
_ = core_waitlist:update(Wid, [{status, left}]),
maybe_promote(Event#event.id);
false ->
Status = initial_status(Cal#calendar.confirmation),
case core_booking:create(Event#event.id, UserId, Status) of
{ok, Booking} ->
_ = core_waitlist:update(Wid, [{status, promoted}]),
notify_promoted(UserId, Event, Booking),
ok;
{error, _} ->
ok
end
end
end.
initial_status(auto) -> confirmed;
initial_status(_) -> pending.
notify_promoted(UserId, Event, Booking) ->
Title = Event#event.title,
Path = <<"/c/", (Event#event.calendar_id)/binary, "/e/", (Event#event.id)/binary>>,
case core_user:get_by_id(UserId) of
{ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
_ = logic_email:send_waitlist_promoted(Email, Title, Path);
_ ->
ok
end,
_ = logic_notification:notify_waitlist_promoted(UserId, #{
event_id => Event#event.id,
calendar_id => Event#event.calendar_id,
booking_id => Booking#booking.id,
title => Title,
status => Booking#booking.status
}),
ok.
%%%===================================================================
%%% INTERNAL
%%%===================================================================
is_full(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
false;
is_full(EventId, Cap) when is_integer(Cap), Cap > 0 ->
occupied(EventId) >= Cap;
is_full(_, _) ->
false.
has_free_slot(_EventId, Cap) when Cap =:= undefined; Cap =:= 0 ->
true;
has_free_slot(EventId, Cap) when is_integer(Cap), Cap > 0 ->
occupied(EventId) < Cap;
has_free_slot(_, _) ->
true.
occupied(EventId) ->
{ok, Bookings} = core_booking:list_by_event(EventId),
length([B || B <- Bookings,
B#booking.status =:= pending orelse B#booking.status =:= confirmed]).
logic_booking_occupied(EventId, UserId) ->
Match = #booking{event_id = EventId, user_id = UserId, _ = '_'},
case [B || B <- mnesia:dirty_match_object(Match),
B#booking.status =:= pending orelse B#booking.status =:= confirmed] of
[_ | _] -> true;
[] -> false
end.
sort_waiting(List) ->
lists:sort(fun(A, B) -> A#waitlist_entry.created_at =< B#waitlist_entry.created_at end, List).
position_of(Id, Waiting) ->
position_of(Id, Waiting, 1).
position_of(_Id, [], _) -> null;
position_of(Id, [#waitlist_entry{id = Id} | _], N) -> N;
position_of(Id, [_ | Rest], N) -> position_of(Id, Rest, N + 1).
entry_public(Entry, EventId) ->
Waiting = sort_waiting(core_waitlist:list_waiting_by_event(EventId)),
#{
entry => entry_json(Entry),
position => position_of(Entry#waitlist_entry.id, Waiting),
total => length(Waiting)
}.
entry_json(#waitlist_entry{} = E) ->
#{
id => E#waitlist_entry.id,
event_id => E#waitlist_entry.event_id,
user_id => E#waitlist_entry.user_id,
status => E#waitlist_entry.status,
created_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.created_at),
updated_at => handler_utils:datetime_to_iso8601(E#waitlist_entry.updated_at)
}.
can_manage(UserId, EventId) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
case core_calendar:get_by_id(Event#event.calendar_id) of
{ok, #calendar{owner_id = UserId}} ->
true;
{ok, Calendar} ->
SpecOk = is_binary(Event#event.specialist_id)
andalso Event#event.specialist_id =/= <<>>
andalso Event#event.specialist_id =:= UserId
andalso core_calendar_specialist:is_active_specialist(
Calendar#calendar.id, UserId),
SpecOk orelse admin_utils:is_admin(UserId);
_ ->
admin_utils:is_admin(UserId)
end;
_ ->
false
end.
@@ -0,0 +1,38 @@
%% @doc Create waitlist_entry table and indexes (Back#72).
-module('20260814193000_waitlist_entry').
-export([up/0, down/0]).
-include("records.hrl").
up() ->
ensure_table(waitlist_entry, record_info(fields, waitlist_entry)),
ensure_index(waitlist_entry, event_id),
ensure_index(waitlist_entry, user_id),
ensure_index(waitlist_entry, status),
ok.
down() ->
_ = mnesia:delete_table(waitlist_entry),
ok.
ensure_table(Table, Attrs) ->
case lists:member(Table, mnesia:system_info(tables)) of
true ->
ok;
false ->
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table}} -> ok;
{aborted, Reason} -> error({create_table_failed, Table, Reason})
end
end.
ensure_index(Table, Attr) ->
case mnesia:add_table_index(Table, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, Table, _Pos}} -> ok;
{aborted, {already_exists, Table, Attr}} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
end.
+1
View File
@@ -87,6 +87,7 @@ user() ->
handler_event_by_id,
handler_event_occurrences,
handler_events,
handler_event_waitlist,
handler_reports,
handler_review_by_id,
handler_review_vote,
+3
View File
@@ -87,6 +87,7 @@ ensure_indexes(Tables) when is_list(Tables) ->
{event, [calendar_id, title, created_at, start_time, event_type,
master_id, specialist_id, status]},
{booking, [event_id, user_id, status]},
{waitlist_entry, [event_id, user_id, status]},
{review_vote, [review_id, user_id]},
{calendar, [owner_id, status, short_name, category]},
{calendar_specialist, [calendar_id, user_id]},
@@ -156,6 +157,8 @@ table_opts(recurrence_exception) ->
[{ram_copies, [node()]}, {type, bag}, {attributes, record_info(fields, recurrence_exception)}];
table_opts(booking) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, booking)}];
table_opts(waitlist_entry) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, waitlist_entry)}];
table_opts(review) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, review)}];
table_opts(review_vote) ->
+106
View File
@@ -0,0 +1,106 @@
-module(logic_waitlist_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [user, calendar, event, booking, waitlist_entry, subscription,
notification, calendar_specialist]).
setup() ->
eh_test_support:start_mnesia(),
eh_test_support:ensure_tables(?TABLES),
ok.
cleanup(_) ->
eh_test_support:delete_tables(?TABLES),
eh_test_support:stop_mnesia(),
ok.
logic_waitlist_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"join rejected when waitlist disabled", fun test_disabled/0},
{"join when full and enabled", fun test_join_full/0},
{"join rejected when not full", fun test_not_full/0},
{"promote on cancel", fun test_promote_on_cancel/0},
{"leave waitlist", fun test_leave/0}
]}.
create_user() ->
Id = infra_utils:generate_id(8),
U = #user{
id = Id, email = <<Id/binary, "@t.com">>, password_hash = <<"h">>,
role = user, status = active,
created_at = calendar:universal_time(),
updated_at = calendar:universal_time()
},
mnesia:dirty_write(U),
Id.
ensure_sub(OwnerId) ->
{ok, _} = core_subscription:create(OwnerId, monthly, true),
ok.
create_cal(OwnerId, Waitlist) ->
ensure_sub(OwnerId),
Settings = #{<<"waitlist_enabled">> => Waitlist},
{ok, Cal} = core_calendar:create(OwnerId, <<"Studio">>, <<"">>, auto, commercial),
{ok, Cal2} = core_calendar:update(Cal#calendar.id, [{settings, Settings}]),
Cal2.
create_event(CalId, Cap) ->
Start = eh_test_support:future_start(),
{ok, Ev} = core_event:create(CalId, <<"Slot">>, Start, 60),
{ok, Ev2} = core_event:update(Ev#event.id, [{capacity, Cap}]),
Ev2#event.id.
test_disabled() ->
Owner = create_user(),
Guest = create_user(),
Cal = create_cal(Owner, false),
EventId = create_event(Cal#calendar.id, 1),
{ok, _} = logic_booking:create_booking(Guest, EventId),
Other = create_user(),
?assertEqual({error, waitlist_disabled}, logic_waitlist:join(Other, EventId)).
test_join_full() ->
Owner = create_user(),
Guest = create_user(),
Cal = create_cal(Owner, true),
EventId = create_event(Cal#calendar.id, 1),
{ok, _} = logic_booking:create_booking(Guest, EventId),
Other = create_user(),
{ok, Body} = logic_waitlist:join(Other, EventId),
?assertEqual(1, maps:get(position, Body)).
test_not_full() ->
Owner = create_user(),
Cal = create_cal(Owner, true),
EventId = create_event(Cal#calendar.id, 2),
?assertEqual({error, not_full}, logic_waitlist:join(create_user(), EventId)).
test_promote_on_cancel() ->
application:set_env(eventhub, smtp_host, undefined),
Owner = create_user(),
Guest = create_user(),
Waiter = create_user(),
Cal = create_cal(Owner, true),
EventId = create_event(Cal#calendar.id, 1),
{ok, Booking} = logic_booking:create_booking(Guest, EventId),
{ok, _} = logic_waitlist:join(Waiter, EventId),
{ok, _} = logic_booking:cancel_booking(Booking#booking.id, Guest),
{ok, WaiterBookings} = core_booking:list_by_user(Waiter),
Active = [B || B <- WaiterBookings,
B#booking.event_id =:= EventId,
B#booking.status =:= confirmed orelse B#booking.status =:= pending],
?assertEqual(1, length(Active)),
?assertEqual({error, not_found}, core_waitlist:get_waiting(EventId, Waiter)).
test_leave() ->
Owner = create_user(),
Guest = create_user(),
Waiter = create_user(),
Cal = create_cal(Owner, true),
EventId = create_event(Cal#calendar.id, 1),
{ok, _} = logic_booking:create_booking(Guest, EventId),
{ok, _} = logic_waitlist:join(Waiter, EventId),
?assertEqual(ok, logic_waitlist:leave(Waiter, EventId)),
?assertEqual({error, not_found}, core_waitlist:get_waiting(EventId, Waiter)).