feat(#20): довести RSVP и напоминания до полного набора событий
PR Checks / test-and-build (pull_request) Successful in 3m12s

- Добавлена абстракция 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>
This commit is contained in:
2026-05-11 13:38:34 +03:00
parent 563e118f23
commit e6e6d17b72
17 changed files with 434 additions and 88 deletions
@@ -0,0 +1,6 @@
namespace GmRelay.Bot.Features.Confirmation.SendConfirmation;
public interface ISendConfirmationHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -32,7 +32,7 @@ public sealed class SendConfirmationHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendConfirmationHandler> logger)
ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
@@ -103,12 +103,13 @@ public sealed class SendConfirmationHandler(
replyMarkup: keyboard,
cancellationToken: ct);
// 5. Update session status and store message ID
// 5. Update session status, store message ID, and mark confirmation sent
await connection.ExecuteAsync(
"""
UPDATE sessions
SET status = @Status,
confirmation_message_id = @MessageId,
confirmation_sent_at = now(),
updated_at = now()
WHERE id = @SessionId
""",
@@ -0,0 +1,6 @@
namespace GmRelay.Bot.Features.Reminders.SendJoinLink;
public interface ISendJoinLinkHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -31,7 +31,7 @@ public sealed class SendJoinLinkHandler(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ILogger<SendJoinLinkHandler> logger)
ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
@@ -0,0 +1,6 @@
namespace GmRelay.Bot.Features.Reminders.SendOneHourReminder;
public interface ISendOneHourReminderHandler
{
Task HandleAsync(Guid sessionId, CancellationToken ct);
}
@@ -15,7 +15,7 @@ internal sealed record OneHourReminderSession(
public sealed class SendOneHourReminderHandler(
NpgsqlDataSource dataSource,
DirectSessionNotificationSender directSender,
ILogger<SendOneHourReminderHandler> logger)
ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
{
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
{
@@ -1,5 +1,6 @@
using Dapper;
using GmRelay.Bot.Features.Notifications;
using GmRelay.Bot.Infrastructure.Scheduling;
using GmRelay.Shared.Domain;
using GmRelay.Shared.Rendering;
using Npgsql;
@@ -25,6 +26,7 @@ public sealed class RescheduleVotingDeadlineService(
NpgsqlDataSource dataSource,
ITelegramBotClient bot,
DirectSessionNotificationSender directSender,
ISystemClock clock,
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -55,10 +57,11 @@ public sealed class RescheduleVotingDeadlineService(
FROM reschedule_proposals
WHERE status = 'Voting'
AND voting_deadline_at IS NOT NULL
AND voting_deadline_at <= now()
AND voting_deadline_at <= @Now
ORDER BY voting_deadline_at
LIMIT 25
""")).ToList();
""",
new { Now = clock.UtcNow.UtcDateTime })).ToList();
foreach (var proposalId in proposalIds)
{
@@ -97,10 +100,10 @@ public sealed class RescheduleVotingDeadlineService(
WHERE rp.id = @ProposalId
AND rp.status = 'Voting'
AND rp.voting_deadline_at IS NOT NULL
AND rp.voting_deadline_at <= now()
AND rp.voting_deadline_at <= @Now
FOR UPDATE
""",
new { ProposalId = proposalId },
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime },
transaction);
if (proposal is null)