Files
GmRelayBot/src/GmRelay.Bot/Infrastructure/Scheduling/ISessionTriggerStore.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

87 lines
3.0 KiB
C#

using Dapper;
using GmRelay.Shared.Domain;
using Npgsql;
namespace GmRelay.Bot.Infrastructure.Scheduling;
public interface ISessionTriggerStore
{
Task<IReadOnlyList<Guid>> GetSessionsNeedingConfirmationAsync(DateTimeOffset now, CancellationToken ct);
Task<IReadOnlyList<Guid>> GetSessionsNeedingOneHourReminderAsync(DateTimeOffset now, CancellationToken ct);
Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct);
}
public sealed class DbSessionTriggerStore(NpgsqlDataSource dataSource) : ISessionTriggerStore
{
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
public async Task<IReadOnlyList<Guid>> GetSessionsNeedingConfirmationAsync(DateTimeOffset now, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var results = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Planned
AND scheduled_at - @LeadTime <= @Now
AND confirmation_sent_at IS NULL
""",
new
{
Planned = SessionStatus.Planned,
LeadTime = ConfirmationLeadTime,
Now = now.UtcDateTime
});
return results.ToList();
}
public async Task<IReadOnlyList<Guid>> GetSessionsNeedingOneHourReminderAsync(DateTimeOffset now, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var results = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status IN (@Confirmed, @ConfirmationSent)
AND scheduled_at - @LeadTime <= @Now
AND one_hour_reminder_processed_at IS NULL
""",
new
{
Confirmed = SessionStatus.Confirmed,
ConfirmationSent = SessionStatus.ConfirmationSent,
LeadTime = OneHourReminderLeadTime,
Now = now.UtcDateTime
});
return results.ToList();
}
public async Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(DateTimeOffset now, CancellationToken ct)
{
await using var connection = await dataSource.OpenConnectionAsync(ct);
var results = await connection.QueryAsync<Guid>(
"""
SELECT id
FROM sessions
WHERE status = @Confirmed
AND scheduled_at - @LeadTime <= @Now
AND link_message_id IS NULL
""",
new
{
Confirmed = SessionStatus.Confirmed,
LeadTime = JoinLinkLeadTime,
Now = now.UtcDateTime
});
return results.ToList();
}
}