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
+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)).