From 15ae8d442627d59e6f04203c3f099b393288f619 Mon Sep 17 00:00:00 2001 From: Aleksey Sabilin Date: Fri, 14 Aug 2026 20:59:38 +0300 Subject: [PATCH] feat(email): send booking reminders before event start. Job process_reminders in subscription_worker; REMINDER_LEAD_HOURS window; CalenTIQ email + in-app event_reminder; idempotent reminder_sent. Refs EventHub/EventHubBack#70 --- docker/.env.example | 2 + docker/docker-compose.stage.yml | 1 + src/infra/subscription_worker.erl | 1 + src/logic/logic_booking.erl | 80 ++++++++++++++++++++++++- src/logic/logic_email.erl | 24 +++++++- src/logic/logic_email_templates.erl | 19 +++++- src/logic/logic_notification.erl | 8 +++ test/unit/logic_booking_tests.erl | 93 ++++++++++++++++++++++++++++- test/unit/logic_email_tests.erl | 23 ++++++- 9 files changed, 245 insertions(+), 6 deletions(-) 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.