diff --git a/docker/.env.example b/docker/.env.example
index 0ebeab8..113fcb9 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -28,4 +28,6 @@ SMTP_PASS=
SMTP_FROM=noreply@calentiq.com
SMTP_TLS=if_available
PUBLIC_APP_URL=https://stage.calentiq.com
+# Окно напоминаний о booking (часы до старта; Back#70)
+REMINDER_LEAD_HOURS=24
diff --git a/docker/docker-compose.stage.yml b/docker/docker-compose.stage.yml
index b124aaa..27a64fc 100644
--- a/docker/docker-compose.stage.yml
+++ b/docker/docker-compose.stage.yml
@@ -48,6 +48,7 @@ services:
- SMTP_FROM=${SMTP_FROM:-noreply@calentiq.com}
- SMTP_TLS=${SMTP_TLS:-if_available}
- PUBLIC_APP_URL=${PUBLIC_APP_URL:-https://stage.calentiq.com}
+ - REMINDER_LEAD_HOURS=${REMINDER_LEAD_HOURS:-24}
networks:
eventhub-net:
aliases:
diff --git a/src/infra/subscription_worker.erl b/src/infra/subscription_worker.erl
index e9e8a03..3a88544 100755
--- a/src/infra/subscription_worker.erl
+++ b/src/infra/subscription_worker.erl
@@ -26,6 +26,7 @@ handle_cast(_Msg, State) ->
handle_info(tick, State) ->
_ = catch logic_subscription:handle_expired_subscriptions(),
_ = catch logic_booking:process_timeout_bookings(),
+ _ = catch logic_booking:process_reminders(),
erlang:send_after(?INTERVAL_MS, self(), tick),
{noreply, State};
handle_info(_Info, State) ->
diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl
index d8d05ab..1bb183c 100755
--- a/src/logic/logic_booking.erl
+++ b/src/logic/logic_booking.erl
@@ -7,7 +7,7 @@
delete_booking/2,
list_bookings_admin/0, get_booking_admin/1,
list_event_bookings/1, list_event_bookings/2,
- process_timeout_bookings/0, cancel_pending_for_owner/1,
+ process_timeout_bookings/0, process_reminders/0, cancel_pending_for_owner/1,
cancel_pending_for_calendar/1]).
%%%-------------------------------------------------------------------
@@ -367,6 +367,84 @@ process_timeout_bookings() ->
lists:foreach(fun(B) -> maybe_timeout(B, NowSec) end, Pending),
ok.
+%%%-------------------------------------------------------------------
+%%% @doc Email + in-app reminder before event start (Back#70).
+%%% Confirmed bookings with reminder_sent=false whose event starts within
+%%% REMINDER_LEAD_HOURS (default 24). Flag set before send (once).
+%%% @end
+%%%-------------------------------------------------------------------
+-spec process_reminders() -> ok.
+process_reminders() ->
+ NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()),
+ LeadSec = reminder_lead_hours() * 3600,
+ Horizon = NowSec + LeadSec,
+ Candidates = mnesia:dirty_match_object(
+ #booking{status = confirmed, reminder_sent = false, _ = '_'}),
+ lists:foreach(fun(B) -> maybe_remind(B, NowSec, Horizon) end, Candidates),
+ ok.
+
+maybe_remind(#booking{id = Id, event_id = EventId, user_id = UserId} = Booking,
+ NowSec, Horizon) ->
+ case core_event:get_by_id(EventId) of
+ {ok, #event{status = active, start_time = Start, title = Title,
+ calendar_id = CalId} = _Event} ->
+ StartSec = calendar:datetime_to_gregorian_seconds(Start),
+ case StartSec > NowSec andalso StartSec =< Horizon of
+ true ->
+ case core_booking:update(Id, [{reminder_sent, true}]) of
+ {ok, _} ->
+ send_reminder(UserId, Title, Start, CalId, EventId),
+ ok;
+ _ ->
+ ok
+ end;
+ false ->
+ ok
+ end;
+ _ ->
+ ok
+ end,
+ Booking.
+
+send_reminder(UserId, Title, Start, CalId, EventId) ->
+ Path = <<"/c/", CalId/binary, "/e/", EventId/binary>>,
+ WhenText = format_when(Start),
+ case core_user:get_by_id(UserId) of
+ {ok, #user{email = Email}} when is_binary(Email), Email =/= <<>> ->
+ _ = logic_email:send_booking_reminder(Email, Title, Start, Path);
+ _ ->
+ ok
+ end,
+ _ = logic_notification:notify_event_reminder(UserId, #{
+ event_id => EventId,
+ calendar_id => CalId,
+ title => Title,
+ start_time => Start,
+ when_text => WhenText
+ }),
+ ok.
+
+format_when({{Y, Mo, D}, {H, Mi, _S}}) ->
+ iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
+ [Y, Mo, D, H, Mi])).
+
+reminder_lead_hours() ->
+ case application:get_env(eventhub, reminder_lead_hours) of
+ {ok, N} when is_integer(N), N > 0 -> N;
+ _ ->
+ case os:getenv("REMINDER_LEAD_HOURS") of
+ false -> 24;
+ "" -> 24;
+ S ->
+ try list_to_integer(S) of
+ N when N > 0 -> N;
+ _ -> 24
+ catch
+ _:_ -> 24
+ end
+ end
+ end.
+
maybe_timeout(#booking{id = Id, event_id = EventId, created_at = Created} = Booking, NowSec) ->
case core_event:get_by_id(EventId) of
{ok, Event} ->
diff --git a/src/logic/logic_email.erl b/src/logic/logic_email.erl
index a4cee2c..ae2f498 100755
--- a/src/logic/logic_email.erl
+++ b/src/logic/logic_email.erl
@@ -1,5 +1,6 @@
-module(logic_email).
--export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2]).
+-export([send_verification_email/2, send_specialist_invite/2, send_password_reset/2,
+ send_booking_reminder/4]).
%% SMTP via env (variant A / Back#68):
%% SMTP_HOST empty → log-only (local/tests)
@@ -19,6 +20,15 @@ send_specialist_invite(Email, Token) ->
send_password_reset(Email, Token) ->
send_kind(reset, Email, abs_url(<<"/reset-password?token=">>, Token)).
+%% Reminder before event start (Back#70).
+-spec send_booking_reminder(binary(), binary(), calendar:datetime(), binary()) -> ok.
+send_booking_reminder(Email, EventTitle, StartTime, EventPath) ->
+ Url = abs_path(EventPath),
+ WhenText = format_utc(StartTime),
+ {Subject, Plain, Html} =
+ logic_email_templates:render_reminder(Url, to_bin(EventTitle), WhenText),
+ 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).
@@ -99,6 +109,18 @@ abs_url(PathPrefix, Token) ->
TokenB = to_bin(Token),
<>.
+abs_path(Path0) ->
+ Base = strip_slash(env_bin("PUBLIC_APP_URL", <<"https://stage.calentiq.com">>)),
+ Path = case to_bin(Path0) of
+ <<$/, Rest/binary>> -> <<$/, Rest/binary>>;
+ Rest -> <<$/, Rest/binary>>
+ end,
+ <>.
+
+format_utc({{Y, Mo, D}, {H, Mi, _S}}) ->
+ iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0B ~2..0B:~2..0B",
+ [Y, Mo, D, H, Mi])).
+
strip_slash(Url) ->
case binary:last(Url) of
$/ -> binary:part(Url, 0, byte_size(Url) - 1);
diff --git a/src/logic/logic_email_templates.erl b/src/logic/logic_email_templates.erl
index ec534ee..3ca97c4 100644
--- a/src/logic/logic_email_templates.erl
+++ b/src/logic/logic_email_templates.erl
@@ -1,11 +1,28 @@
-module(logic_email_templates).
--export([render/2]).
+-export([render/2, render_reminder/3]).
%% Kind: verify | invite | reset
-spec render(verify | invite | reset, binary()) ->
{Subject :: binary(), Plain :: binary(), Html :: binary()}.
render(Kind, Url) ->
{Subject, Heading, Lead, Cta, Foot, Pre} = copy(Kind),
+ assemble(Subject, Heading, Lead, Url, Cta, Foot, Pre).
+
+%% Booking reminder (Back#70): title + when text in lead.
+-spec render_reminder(binary(), binary(), binary()) ->
+ {Subject :: binary(), Plain :: binary(), Html :: binary()}.
+render_reminder(Url, Title, WhenText) ->
+ Subject = <<"CalenTIQ: напоминание о записи">>,
+ Heading = <<"Напоминание о записи">>,
+ Lead = iolist_to_binary([
+ <<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC).">>
+ ]),
+ Cta = <<"Открыть запись">>,
+ Foot = <<"Вы получили письмо, потому что записаны на это событие в CalenTIQ.">>,
+ 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(), [
{<<"{{preheader}}">>, Pre},
diff --git a/src/logic/logic_notification.erl b/src/logic/logic_notification.erl
index bb510e5..dd0ad97 100755
--- a/src/logic/logic_notification.erl
+++ b/src/logic/logic_notification.erl
@@ -6,6 +6,7 @@
-export([notify_event_update/1]).
-export([notify_admin/2]).
-export([notify_specialist_invite/2]).
+-export([notify_event_reminder/2]).
%% Уведомление о бронировании
notify_booking(UserId, Booking) ->
@@ -45,6 +46,13 @@ notify_specialist_invite(UserId, Invite) ->
},
broadcast_to_user(UserId, specialist_invite, Data).
+%% In-app / WS: напоминание о записи (Back#70)
+notify_event_reminder(UserId, #{title := Title, when_text := WhenText} = Data) ->
+ NTitle = <<"Напоминание о записи">>,
+ Body = iolist_to_binary([<<"Скоро: «">>, Title, <<"» — ">>, WhenText, <<" (UTC)">>]),
+ _ = core_notification:create(UserId, event_reminder, NTitle, Body),
+ broadcast_to_user(UserId, event_reminder, Data#{user_id => UserId}).
+
%% Уведомление для администраторов
notify_admin(Type, Data) ->
Message = {admin_notification, Type, Data},
diff --git a/test/unit/logic_booking_tests.erl b/test/unit/logic_booking_tests.erl
index 7b43242..38cd873 100755
--- a/test/unit/logic_booking_tests.erl
+++ b/test/unit/logic_booking_tests.erl
@@ -3,7 +3,7 @@
-include("records.hrl").
-define(TABLES, [user, calendar, event, booking, admin, subscription, calendar_specialist,
- recurrence_exception]).
+ recurrence_exception, notification]).
setup() ->
eh_test_support:start_mnesia(),
@@ -54,7 +54,10 @@ logic_booking_test_() ->
{"Past pending excluded from booking requests", fun test_past_pending_excluded_from_requests/0},
{"Past pending confirm denied", fun test_past_pending_confirm_denied/0},
{"Future pending still confirmable", fun test_future_pending_still_confirmable/0},
- {"Process timeout expires past pending", fun test_process_timeout_expires_past/0}
+ {"Process timeout expires past pending", fun test_process_timeout_expires_past/0},
+ {"Reminder sends once within lead window", fun test_reminder_within_window/0},
+ {"Reminder skipped outside lead window", fun test_reminder_outside_window/0},
+ {"Reminder skipped for pending", fun test_reminder_skips_pending/0}
]}.
%% Вспомогательные функции
@@ -509,3 +512,89 @@ test_process_timeout_expires_past() ->
ok = logic_booking:process_timeout_bookings(),
{ok, Stored} = core_booking:get_by_id(Booking#booking.id),
?assertEqual(expired, Stored#booking.status).
+
+hours_from_now(Hours) ->
+ Sec = calendar:datetime_to_gregorian_seconds(calendar:universal_time())
+ + Hours * 3600,
+ calendar:gregorian_seconds_to_datetime(Sec).
+
+test_reminder_within_window() ->
+ application:set_env(eventhub, reminder_lead_hours, 24),
+ Self = self(),
+ application:set_env(eventhub, smtp_host, "smtp.test"),
+ application:set_env(eventhub, smtp_deliver,
+ fun(_From, To, Raw, _Opts) ->
+ Self ! {smtp, To, Raw},
+ {ok, mock}
+ end),
+ OwnerId = create_test_user(user),
+ ParticipantId = create_test_user(user),
+ CalendarId = create_test_calendar(OwnerId, auto),
+ EventId = create_test_event_at(CalendarId, hours_from_now(2)),
+ {ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
+ ?assertEqual(confirmed, Booking#booking.status),
+ ?assertEqual(false, Booking#booking.reminder_sent),
+ ok = logic_booking:process_reminders(),
+ receive
+ {smtp, To, Raw} ->
+ ?assertEqual(<>, To),
+ ?assert(binary:match(Raw, <<"CalenTIQ">>) =/= nomatch),
+ ?assert(binary:match(Raw, <<"/c/", CalendarId/binary, "/e/", EventId/binary>>)
+ =/= nomatch)
+ after 1000 ->
+ error(timeout_reminder_email)
+ end,
+ {ok, Stored} = core_booking:get_by_id(Booking#booking.id),
+ ?assertEqual(true, Stored#booking.reminder_sent),
+ Notifs = core_notification:list_by_user(ParticipantId),
+ ?assert(lists:any(fun(#notification{type = event_reminder}) -> true; (_) -> false end,
+ Notifs)),
+ %% second pass: no duplicate email
+ ok = logic_booking:process_reminders(),
+ receive
+ {smtp, _, _} -> error(duplicate_reminder)
+ after 200 ->
+ ok
+ end,
+ application:unset_env(eventhub, smtp_host),
+ application:unset_env(eventhub, smtp_deliver),
+ application:unset_env(eventhub, reminder_lead_hours).
+
+test_reminder_outside_window() ->
+ application:set_env(eventhub, reminder_lead_hours, 24),
+ Self = self(),
+ application:set_env(eventhub, smtp_host, "smtp.test"),
+ application:set_env(eventhub, smtp_deliver,
+ fun(_From, To, Raw, _Opts) ->
+ Self ! {smtp, To, Raw},
+ {ok, mock}
+ end),
+ OwnerId = create_test_user(user),
+ ParticipantId = create_test_user(user),
+ CalendarId = create_test_calendar(OwnerId, auto),
+ EventId = create_test_event_at(CalendarId, hours_from_now(48)),
+ {ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
+ ok = logic_booking:process_reminders(),
+ receive
+ {smtp, _, _} -> error(unexpected_reminder)
+ after 200 ->
+ ok
+ end,
+ {ok, Stored} = core_booking:get_by_id(Booking#booking.id),
+ ?assertEqual(false, Stored#booking.reminder_sent),
+ application:unset_env(eventhub, smtp_host),
+ application:unset_env(eventhub, smtp_deliver),
+ application:unset_env(eventhub, reminder_lead_hours).
+
+test_reminder_skips_pending() ->
+ application:set_env(eventhub, reminder_lead_hours, 24),
+ OwnerId = create_test_user(user),
+ ParticipantId = create_test_user(user),
+ CalendarId = create_test_calendar(OwnerId, manual),
+ EventId = create_test_event_at(CalendarId, hours_from_now(2)),
+ {ok, Booking} = logic_booking:create_booking(ParticipantId, EventId),
+ ?assertEqual(pending, Booking#booking.status),
+ ok = logic_booking:process_reminders(),
+ {ok, Stored} = core_booking:get_by_id(Booking#booking.id),
+ ?assertEqual(false, Stored#booking.reminder_sent),
+ application:unset_env(eventhub, reminder_lead_hours).
diff --git a/test/unit/logic_email_tests.erl b/test/unit/logic_email_tests.erl
index b576647..7bcfa88 100644
--- a/test/unit/logic_email_tests.erl
+++ b/test/unit/logic_email_tests.erl
@@ -7,7 +7,8 @@ logic_email_test_() ->
{"mock deliver gets CalenTIQ + verify url", fun test_verify_deliver/0},
{"invite and reset urls", fun test_invite_reset/0},
{"deliver error still ok", fun test_deliver_error/0},
- {"html template placeholders filled", fun test_template_fill/0}
+ {"html template placeholders filled", fun test_template_fill/0},
+ {"booking reminder deliver", fun test_reminder_deliver/0}
]}.
setup() ->
@@ -81,3 +82,23 @@ test_template_fill() ->
?assert(binary:match(Plain, <<"https://x/verify?token=z">>) =/= nomatch),
?assert(binary:match(Html, <<"{{">>) =:= nomatch),
?assert(binary:match(Html, <<"https://x/verify?token=z">>) =/= nomatch).
+
+test_reminder_deliver() ->
+ Self = self(),
+ application:set_env(eventhub, smtp_host, "smtp.test"),
+ application:set_env(eventhub, smtp_deliver,
+ fun(_From, To, Raw, _Opts) ->
+ Self ! {smtp, To, Raw},
+ {ok, mock}
+ end),
+ Start = {{2026, 8, 15}, {10, 30, 0}},
+ ?assertEqual(ok, logic_email:send_booking_reminder(
+ <<"u@ex.com">>, <<"Yoga hour">>, Start, <<"/c/cal1/e/ev1">>)),
+ receive
+ {smtp, <<"u@ex.com">>, Raw} ->
+ ?assert(binary:match(Raw, <<"/c/cal1/e/ev1">>) =/= nomatch),
+ ?assert(binary:match(Raw, <<"Yoga hour">>) =/= nomatch),
+ ?assert(binary:match(Raw, <<"2026-08-15 10:30">>) =/= nomatch)
+ after 1000 ->
+ error(timeout)
+ end.