diff --git a/include/records.hrl b/include/records.hrl index 2da130d..6dc121b 100755 --- a/include/records.hrl +++ b/include/records.hrl @@ -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(), diff --git a/src/core/core_waitlist.erl b/src/core/core_waitlist.erl new file mode 100644 index 0000000..2b5359d --- /dev/null +++ b/src/core/core_waitlist.erl @@ -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. diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index d32472d..5e0a709 100755 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -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, []}, diff --git a/src/handlers/handler_event_waitlist.erl b/src/handlers/handler_event_waitlist.erl new file mode 100644 index 0000000..6a51de5 --- /dev/null +++ b/src/handlers/handler_event_waitlist.erl @@ -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. diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index 7933091..5417a71 100755 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -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), diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index 1bb183c..11ebe19 100755 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -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) -> diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index dd78915..3cd463e 100755 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -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. diff --git a/src/logic/logic_email.erl b/src/logic/logic_email.erl index ae2f498..eb227c3 100755 --- a/src/logic/logic_email.erl +++ b/src/logic/logic_email.erl @@ -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). diff --git a/src/logic/logic_email_templates.erl b/src/logic/logic_email_templates.erl index 3ca97c4..ba7665e 100644 --- a/src/logic/logic_email_templates.erl +++ b/src/logic/logic_email_templates.erl @@ -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(), [ diff --git a/src/logic/logic_notification.erl b/src/logic/logic_notification.erl index dd0ad97..442a1e4 100755 --- a/src/logic/logic_notification.erl +++ b/src/logic/logic_notification.erl @@ -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}, diff --git a/src/logic/logic_waitlist.erl b/src/logic/logic_waitlist.erl new file mode 100644 index 0000000..9558f6d --- /dev/null +++ b/src/logic/logic_waitlist.erl @@ -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. diff --git a/src/migrations/20260814193000_waitlist_entry.erl b/src/migrations/20260814193000_waitlist_entry.erl new file mode 100644 index 0000000..9a1edf2 --- /dev/null +++ b/src/migrations/20260814193000_waitlist_entry.erl @@ -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. diff --git a/src/swagger/eventhub_trails.erl b/src/swagger/eventhub_trails.erl index 37bc78e..25aac67 100755 --- a/src/swagger/eventhub_trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -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, diff --git a/test/unit/eh_test_support.erl b/test/unit/eh_test_support.erl index 67786ba..060545a 100755 --- a/test/unit/eh_test_support.erl +++ b/test/unit/eh_test_support.erl @@ -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) -> diff --git a/test/unit/logic_waitlist_tests.erl b/test/unit/logic_waitlist_tests.erl new file mode 100644 index 0000000..e33956f --- /dev/null +++ b/test/unit/logic_waitlist_tests.erl @@ -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 = <>, 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)).