Files
GmRelayBot/src/GmRelay.Bot/Features/Reminders/SendOneHourReminder/SendOneHourReminderHandler.cs
T
Toutsu e6e6d17b72
PR Checks / test-and-build (pull_request) Successful in 3m12s
feat(#20): довести RSVP и напоминания до полного набора событий
- Добавлена абстракция ISystemClock + SystemClock / FakeSystemClock
  для тестируемого scheduling.
- Добавлена миграция V014: confirmation_sent_at в sessions.
- Обновлен SendConfirmationHandler: записывает confirmation_sent_at.
- Обновлен SessionSchedulerService:
  - выделен ISessionTriggerStore / DbSessionTriggerStore
  - SQL-запросы используют параметр @Now вместо now()
  - добавлен публичный TickAsync для тестов
  - защита от дублей через confirmation_sent_at IS NULL
- Обновлен RescheduleVotingDeadlineService: использует ISystemClock.
- Добавлены интерфейсы ISendConfirmationHandler, ISendOneHourReminderHandler,
  ISendJoinLinkHandler для unit-тестируемости.
- Добавлены 8 unit-тестов SessionSchedulerService:
  - все 3 триггера (T-24h, T-1h, T-5min)
  - идемпотентность при повторном запуске
  - ошибки handler не падают и не блокируют другие сессии
  - ошибки store логируются без падения worker-а

Bump version -> 1.13.0

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:38:34 +03:00

98 lines
3.4 KiB
C#

using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
internal sealed record OneHourReminderSession(
Guid Id,
string Title,
string JoinLink,
DateTime ScheduledAt,
string NotificationMode);
public sealed class SendOneHourReminderHandler(
NpgsqlDataSource dataSource,
DirectSessionNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var session = await connection.QuerySingleOrDefaultAsync<OneHourReminderSession>(
"""
SELECT id,
title,
join_link AS JoinLink,
scheduled_at AS ScheduledAt,
notification_mode AS NotificationMode
FROM sessions
WHERE id = @SessionId
AND status IN (@Confirmed, @ConfirmationSent)
AND one_hour_reminder_processed_at IS NULL
""",
new
{
SessionId = sessionId,
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent
});
if (session is null)
{
logger.LogWarning("Session {SessionId} not eligible for one-hour reminder", sessionId);
return;
}
var recipients = (await connection.QueryAsync<DirectNotificationRecipient>(
"""
SELECT p.telegram_id AS TelegramId,
p.display_name AS DisplayName
FROM session_participants sp
JOIN players p ON p.id = sp.player_id
WHERE sp.session_id = @SessionId
AND sp.is_gm = false
AND sp.registration_status = @Active
AND sp.rsvp_status != @Declined
""",
new
{
SessionId = sessionId,
Active = ParticipantRegistrationStatus.Active,
Declined = RsvpStatus.Declined
})).ToList();
var mode = SessionNotificationModeExtensions.FromDatabaseValue(session.NotificationMode);
if (mode.ShouldSendDirectMessages() && recipients.Count > 0)
{
var text = $"""
⏰ <b>Игра начнётся примерно через 1 час</b>
📌 <b>{System.Net.WebUtility.HtmlEncode(session.Title)}</b>
📅 {session.ScheduledAt.FormatMoscow()} (МСК)
🔗 {System.Net.WebUtility.HtmlEncode(session.JoinLink)}
""";
await directSender.SendAsync(recipients, text, "one-hour-reminder", session.Id, ct);
}
await connection.ExecuteAsync(
"""
UPDATE sessions
SET one_hour_reminder_processed_at = now(),
updated_at = now()
WHERE id = @SessionId
AND one_hour_reminder_processed_at IS NULL
""",
new { SessionId = sessionId });
logger.LogInformation(
"One-hour reminder processed for session {SessionId} ({Title}) with mode {NotificationMode}",
sessionId,
session.Title,
session.NotificationMode);
}
}