Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a3285996e | |||
| 025c7c2f9a | |||
| e6e6d17b72 | |||
| 563e118f23 | |||
| e2303490e9 | |||
| 9c1c6c2483 | |||
| c0c8f852d2 | |||
| ac6e2455a1 | |||
| 9374ff16ed | |||
| 17b92b25f4 | |||
| d2edbf16cc | |||
| b16627c2b6 | |||
| 4f7afb3bc9 | |||
| 5baf63e9ad | |||
| a0d9d1bc44 | |||
| f46f2bb5d3 | |||
| 46527fe761 |
@@ -6,7 +6,7 @@ on:
|
|||||||
- main
|
- main
|
||||||
|
|
||||||
env:
|
env:
|
||||||
VERSION: 1.10.0
|
VERSION: 1.13.0
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
# ЧАСТЬ 1: Собираем образы и кладем в Gitea (чтобы делиться с ребятами)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>1.10.2</Version>
|
<Version>1.13.0</Version>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<LangVersion>preview</LangVersion>
|
<LangVersion>preview</LangVersion>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
bot:
|
bot:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.10.2
|
image: git.codeanddice.ru/toutsu/gmrelay-bot:1.13.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
@@ -30,7 +30,7 @@ services:
|
|||||||
- gmrelay
|
- gmrelay
|
||||||
|
|
||||||
web:
|
web:
|
||||||
image: git.codeanddice.ru/toutsu/gmrelay-web:1.10.2
|
image: git.codeanddice.ru/toutsu/gmrelay-web:1.13.0
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -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,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<SendConfirmationHandler> logger)
|
ILogger<SendConfirmationHandler> logger) : ISendConfirmationHandler
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -103,14 +103,16 @@ public sealed class SendConfirmationHandler(
|
|||||||
replyMarkup: keyboard,
|
replyMarkup: keyboard,
|
||||||
cancellationToken: ct);
|
cancellationToken: ct);
|
||||||
|
|
||||||
// 5. Update session status and store message ID
|
// 5. Update session status, store message ID, and mark confirmation sent
|
||||||
await connection.ExecuteAsync(
|
await connection.ExecuteAsync(
|
||||||
"""
|
"""
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET status = @Status,
|
SET status = @Status,
|
||||||
confirmation_message_id = @MessageId,
|
confirmation_message_id = @MessageId,
|
||||||
|
confirmation_sent_at = now(),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = @SessionId
|
WHERE id = @SessionId
|
||||||
|
AND confirmation_sent_at IS NULL
|
||||||
""",
|
""",
|
||||||
new
|
new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<SendJoinLinkHandler> logger)
|
ILogger<SendJoinLinkHandler> logger) : ISendJoinLinkHandler
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
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);
|
||||||
|
}
|
||||||
+1
-1
@@ -15,7 +15,7 @@ internal sealed record OneHourReminderSession(
|
|||||||
public sealed class SendOneHourReminderHandler(
|
public sealed class SendOneHourReminderHandler(
|
||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
ILogger<SendOneHourReminderHandler> logger)
|
ILogger<SendOneHourReminderHandler> logger) : ISendOneHourReminderHandler
|
||||||
{
|
{
|
||||||
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
public async Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public sealed class CancelSessionHandler(
|
|||||||
|
|
||||||
// 3. Загружаем весь батч для перерисовки
|
// 3. Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers
|
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at",
|
ORDER BY scheduled_at",
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ public sealed class CreateSessionHandler(
|
|||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers));
|
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, parseResult.MaxPlayers, link));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ public sealed class JoinSessionHandler(
|
|||||||
|
|
||||||
// Загружаем весь батч для перерисовки
|
// Загружаем весь батч для перерисовки
|
||||||
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = await connection.QueryAsync<SessionBatchDto>(
|
||||||
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers
|
@"SELECT id as SessionId, scheduled_at as ScheduledAt, status as Status, max_players as MaxPlayers, join_link as JoinLink
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at",
|
ORDER BY scheduled_at",
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ public sealed class LeaveSessionHandler(
|
|||||||
SELECT id AS SessionId,
|
SELECT id AS SessionId,
|
||||||
scheduled_at AS ScheduledAt,
|
scheduled_at AS ScheduledAt,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
max_players AS MaxPlayers
|
max_players AS MaxPlayers,
|
||||||
|
join_link AS JoinLink
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ public sealed class PromoteWaitlistedPlayerHandler(
|
|||||||
SELECT id AS SessionId,
|
SELECT id AS SessionId,
|
||||||
scheduled_at AS ScheduledAt,
|
scheduled_at AS ScheduledAt,
|
||||||
status AS Status,
|
status AS Status,
|
||||||
max_players AS MaxPlayers
|
max_players AS MaxPlayers,
|
||||||
|
join_link AS JoinLink
|
||||||
FROM sessions
|
FROM sessions
|
||||||
WHERE batch_id = @BatchId
|
WHERE batch_id = @BatchId
|
||||||
ORDER BY scheduled_at
|
ORDER BY scheduled_at
|
||||||
|
|||||||
+3
-1
@@ -224,6 +224,8 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET scheduled_at = @NewTime,
|
SET scheduled_at = @NewTime,
|
||||||
status = @Status,
|
status = @Status,
|
||||||
|
confirmation_message_id = NULL,
|
||||||
|
confirmation_sent_at = NULL,
|
||||||
one_hour_reminder_processed_at = NULL,
|
one_hour_reminder_processed_at = NULL,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = @SessionId
|
WHERE id = @SessionId
|
||||||
@@ -357,7 +359,7 @@ public sealed class HandleRescheduleTimeInputHandler(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
await using var conn = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await conn.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { proposal.BatchId })).ToList();
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
+9
-5
@@ -1,5 +1,6 @@
|
|||||||
using Dapper;
|
using Dapper;
|
||||||
using GmRelay.Bot.Features.Notifications;
|
using GmRelay.Bot.Features.Notifications;
|
||||||
|
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
using GmRelay.Shared.Domain;
|
using GmRelay.Shared.Domain;
|
||||||
using GmRelay.Shared.Rendering;
|
using GmRelay.Shared.Rendering;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -25,6 +26,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
NpgsqlDataSource dataSource,
|
NpgsqlDataSource dataSource,
|
||||||
ITelegramBotClient bot,
|
ITelegramBotClient bot,
|
||||||
DirectSessionNotificationSender directSender,
|
DirectSessionNotificationSender directSender,
|
||||||
|
ISystemClock clock,
|
||||||
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
ILogger<RescheduleVotingDeadlineService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
@@ -55,10 +57,11 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
FROM reschedule_proposals
|
FROM reschedule_proposals
|
||||||
WHERE status = 'Voting'
|
WHERE status = 'Voting'
|
||||||
AND voting_deadline_at IS NOT NULL
|
AND voting_deadline_at IS NOT NULL
|
||||||
AND voting_deadline_at <= now()
|
AND voting_deadline_at <= @Now
|
||||||
ORDER BY voting_deadline_at
|
ORDER BY voting_deadline_at
|
||||||
LIMIT 25
|
LIMIT 25
|
||||||
""")).ToList();
|
""",
|
||||||
|
new { Now = clock.UtcNow.UtcDateTime })).ToList();
|
||||||
|
|
||||||
foreach (var proposalId in proposalIds)
|
foreach (var proposalId in proposalIds)
|
||||||
{
|
{
|
||||||
@@ -97,10 +100,10 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
WHERE rp.id = @ProposalId
|
WHERE rp.id = @ProposalId
|
||||||
AND rp.status = 'Voting'
|
AND rp.status = 'Voting'
|
||||||
AND rp.voting_deadline_at IS NOT NULL
|
AND rp.voting_deadline_at IS NOT NULL
|
||||||
AND rp.voting_deadline_at <= now()
|
AND rp.voting_deadline_at <= @Now
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
""",
|
""",
|
||||||
new { ProposalId = proposalId },
|
new { ProposalId = proposalId, Now = clock.UtcNow.UtcDateTime },
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
if (proposal is null)
|
if (proposal is null)
|
||||||
@@ -166,6 +169,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
SET scheduled_at = @NewTime,
|
SET scheduled_at = @NewTime,
|
||||||
status = @Status,
|
status = @Status,
|
||||||
confirmation_message_id = NULL,
|
confirmation_message_id = NULL,
|
||||||
|
confirmation_sent_at = NULL,
|
||||||
link_message_id = NULL,
|
link_message_id = NULL,
|
||||||
one_hour_reminder_processed_at = NULL,
|
one_hour_reminder_processed_at = NULL,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
@@ -286,7 +290,7 @@ public sealed class RescheduleVotingDeadlineService(
|
|||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||||
|
|
||||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { proposal.BatchId })).ToList();
|
new { proposal.BatchId })).ToList();
|
||||||
|
|
||||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
|
|
||||||
|
public interface ISystemClock
|
||||||
|
{
|
||||||
|
DateTimeOffset UtcNow { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class SystemClock : ISystemClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow => DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class FakeSystemClock : ISystemClock
|
||||||
|
{
|
||||||
|
public DateTimeOffset UtcNow { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
@@ -1,31 +1,27 @@
|
|||||||
using Dapper;
|
|
||||||
using GmRelay.Shared.Domain;
|
|
||||||
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||||
using Npgsql;
|
|
||||||
|
|
||||||
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
namespace GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
|
/// Stateless scheduler: wakes every 60 seconds, queries PostgreSQL for actionable sessions.
|
||||||
/// Two triggers:
|
/// Three triggers:
|
||||||
/// T-24h: send confirmation request with inline keyboard
|
/// T-24h: send confirmation request with inline keyboard
|
||||||
|
/// T-1h: send one-hour direct reminder
|
||||||
/// T-5min: send join link to all confirmed players
|
/// T-5min: send join link to all confirmed players
|
||||||
///
|
///
|
||||||
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
|
/// If the Raspberry Pi reboots, nothing is lost — all state is in the DB.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SessionSchedulerService(
|
public sealed class SessionSchedulerService(
|
||||||
NpgsqlDataSource dataSource,
|
ISessionTriggerStore triggerStore,
|
||||||
SendConfirmationHandler confirmationHandler,
|
ISendConfirmationHandler confirmationHandler,
|
||||||
SendOneHourReminderHandler oneHourReminderHandler,
|
ISendOneHourReminderHandler oneHourReminderHandler,
|
||||||
SendJoinLinkHandler joinLinkHandler,
|
ISendJoinLinkHandler joinLinkHandler,
|
||||||
|
ISystemClock clock,
|
||||||
ILogger<SessionSchedulerService> logger) : BackgroundService
|
ILogger<SessionSchedulerService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
private static readonly TimeSpan TickInterval = TimeSpan.FromMinutes(1);
|
||||||
private static readonly TimeSpan ConfirmationLeadTime = TimeSpan.FromHours(24);
|
|
||||||
private static readonly TimeSpan OneHourReminderLeadTime = TimeSpan.FromHours(1);
|
|
||||||
private static readonly TimeSpan JoinLinkLeadTime = TimeSpan.FromMinutes(5);
|
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
@@ -33,14 +29,11 @@ public sealed class SessionSchedulerService(
|
|||||||
|
|
||||||
using var timer = new PeriodicTimer(TickInterval);
|
using var timer = new PeriodicTimer(TickInterval);
|
||||||
|
|
||||||
// Run immediately on startup, then on each tick
|
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ProcessConfirmationTriggers(stoppingToken);
|
await TickAsync(stoppingToken);
|
||||||
await ProcessOneHourReminderTriggers(stoppingToken);
|
|
||||||
await ProcessJoinLinkTriggers(stoppingToken);
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -57,57 +50,30 @@ public sealed class SessionSchedulerService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// T-1h trigger: process direct reminders according to the session notification mode.
|
/// Runs a single scheduler tick using the current clock time.
|
||||||
|
/// Public so it can be called from integration tests with a fake clock.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ProcessOneHourReminderTriggers(CancellationToken ct)
|
public async Task TickAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
var now = clock.UtcNow;
|
||||||
|
|
||||||
var sessionIds = await connection.QueryAsync<Guid>(
|
await ProcessConfirmationTriggers(now, ct);
|
||||||
"""
|
await ProcessOneHourReminderTriggers(now, ct);
|
||||||
SELECT id
|
await ProcessJoinLinkTriggers(now, ct);
|
||||||
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
|
|
||||||
});
|
|
||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
|
||||||
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task ProcessConfirmationTriggers(DateTimeOffset now, CancellationToken ct)
|
||||||
/// T-24h trigger: find sessions that need confirmation requests sent.
|
|
||||||
/// Condition: status='Planned' AND scheduled_at minus 24h is in the past.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ProcessConfirmationTriggers(CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
IReadOnlyList<Guid> sessionIds;
|
||||||
|
try
|
||||||
var sessionIds = await connection.QueryAsync<Guid>(
|
{
|
||||||
"""
|
sessionIds = await triggerStore.GetSessionsNeedingConfirmationAsync(now, ct);
|
||||||
SELECT id
|
}
|
||||||
FROM sessions
|
catch (Exception ex)
|
||||||
WHERE status = @Planned
|
{
|
||||||
AND scheduled_at - @LeadTime <= now()
|
logger.LogError(ex, "Failed to query confirmation triggers");
|
||||||
""",
|
return;
|
||||||
new { Planned = SessionStatus.Planned, LeadTime = ConfirmationLeadTime });
|
}
|
||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
@@ -123,23 +89,45 @@ public sealed class SessionSchedulerService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private async Task ProcessOneHourReminderTriggers(DateTimeOffset now, CancellationToken ct)
|
||||||
/// T-5min trigger: find confirmed sessions that need join links sent.
|
|
||||||
/// Condition: status='Confirmed' AND scheduled_at minus 5min is in the past AND link not yet sent.
|
|
||||||
/// </summary>
|
|
||||||
private async Task ProcessJoinLinkTriggers(CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
IReadOnlyList<Guid> sessionIds;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionIds = await triggerStore.GetSessionsNeedingOneHourReminderAsync(now, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to query one-hour reminder triggers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var sessionIds = await connection.QueryAsync<Guid>(
|
foreach (var sessionId in sessionIds)
|
||||||
"""
|
{
|
||||||
SELECT id
|
try
|
||||||
FROM sessions
|
{
|
||||||
WHERE status = @Confirmed
|
await oneHourReminderHandler.HandleAsync(sessionId, ct);
|
||||||
AND scheduled_at - @LeadTime <= now()
|
logger.LogInformation("One-hour reminder processed for session {SessionId}", sessionId);
|
||||||
AND link_message_id IS NULL
|
}
|
||||||
""",
|
catch (Exception ex)
|
||||||
new { Confirmed = SessionStatus.Confirmed, LeadTime = JoinLinkLeadTime });
|
{
|
||||||
|
logger.LogError(ex, "Failed to process one-hour reminder for session {SessionId}", sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessJoinLinkTriggers(DateTimeOffset now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
IReadOnlyList<Guid> sessionIds;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
sessionIds = await triggerStore.GetSessionsNeedingJoinLinkAsync(now, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "Failed to query join-link triggers");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var sessionId in sessionIds)
|
foreach (var sessionId in sessionIds)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ public static class TelegramSessionBatchRenderer
|
|||||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||||
|
{
|
||||||
|
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
|
||||||
|
}
|
||||||
|
|
||||||
if (session.ActivePlayers.Count > 0)
|
if (session.ActivePlayers.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN confirmation_sent_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Update existing ConfirmationSent sessions to have a sentinel value
|
||||||
|
-- so they don't get re-processed after migration
|
||||||
|
UPDATE sessions
|
||||||
|
SET confirmation_sent_at = now()
|
||||||
|
WHERE status = 'ConfirmationSent';
|
||||||
|
|
||||||
|
-- Partial index for efficient T-24h query
|
||||||
|
CREATE INDEX ix_sessions_confirmation_reminders ON sessions (scheduled_at)
|
||||||
|
WHERE status = 'Planned'
|
||||||
|
AND confirmation_sent_at IS NULL;
|
||||||
@@ -52,10 +52,13 @@ builder.Services.AddSingleton<ITelegramUpdateSource, TelegramUpdateSource>();
|
|||||||
|
|
||||||
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
// ── Feature handlers (explicit registration — AOT safe) ──────────────
|
||||||
builder.Services.AddSingleton<SendConfirmationHandler>();
|
builder.Services.AddSingleton<SendConfirmationHandler>();
|
||||||
|
builder.Services.AddSingleton<ISendConfirmationHandler>(sp => sp.GetRequiredService<SendConfirmationHandler>());
|
||||||
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
builder.Services.AddSingleton<DirectSessionNotificationSender>();
|
||||||
builder.Services.AddSingleton<HandleRsvpHandler>();
|
builder.Services.AddSingleton<HandleRsvpHandler>();
|
||||||
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
builder.Services.AddSingleton<SendJoinLinkHandler>();
|
||||||
|
builder.Services.AddSingleton<ISendJoinLinkHandler>(sp => sp.GetRequiredService<SendJoinLinkHandler>());
|
||||||
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
builder.Services.AddSingleton<SendOneHourReminderHandler>();
|
||||||
|
builder.Services.AddSingleton<ISendOneHourReminderHandler>(sp => sp.GetRequiredService<SendOneHourReminderHandler>());
|
||||||
builder.Services.AddSingleton<CreateSessionHandler>();
|
builder.Services.AddSingleton<CreateSessionHandler>();
|
||||||
builder.Services.AddSingleton<JoinSessionHandler>();
|
builder.Services.AddSingleton<JoinSessionHandler>();
|
||||||
builder.Services.AddSingleton<LeaveSessionHandler>();
|
builder.Services.AddSingleton<LeaveSessionHandler>();
|
||||||
@@ -74,6 +77,10 @@ builder.Services.AddSingleton<ITelegramUpdateHandler>(sp => sp.GetRequiredServic
|
|||||||
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
builder.Services.AddHostedService<TelegramMiniAppMenuButtonService>();
|
||||||
builder.Services.AddHostedService<TelegramBotService>();
|
builder.Services.AddHostedService<TelegramBotService>();
|
||||||
|
|
||||||
|
// ── Clock and scheduling ──────────────────────────────────────────────
|
||||||
|
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
||||||
|
builder.Services.AddSingleton<ISessionTriggerStore, DbSessionTriggerStore>();
|
||||||
|
|
||||||
// ── Session scheduler ────────────────────────────────────────────────
|
// ── Session scheduler ────────────────────────────────────────────────
|
||||||
builder.Services.AddHostedService<SessionSchedulerService>();
|
builder.Services.AddHostedService<SessionSchedulerService>();
|
||||||
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
builder.Services.AddHostedService<RescheduleVotingDeadlineService>();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace GmRelay.Shared.Rendering;
|
namespace GmRelay.Shared.Rendering;
|
||||||
|
|
||||||
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers);
|
public sealed record SessionBatchDto(Guid SessionId, DateTime ScheduledAt, string Status, int? MaxPlayers, string JoinLink);
|
||||||
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
public sealed record ParticipantBatchDto(Guid SessionId, string DisplayName, string? TelegramUsername, string RegistrationStatus);
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ public static class SessionBatchViewBuilder
|
|||||||
session.ScheduledAt,
|
session.ScheduledAt,
|
||||||
session.Status,
|
session.Status,
|
||||||
session.MaxPlayers,
|
session.MaxPlayers,
|
||||||
|
session.JoinLink,
|
||||||
activePlayers.Count,
|
activePlayers.Count,
|
||||||
activePlayers,
|
activePlayers,
|
||||||
waitlistedPlayers,
|
waitlistedPlayers,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public sealed record SessionViewItem(
|
|||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
string Status,
|
string Status,
|
||||||
int? MaxPlayers,
|
int? MaxPlayers,
|
||||||
|
string JoinLink,
|
||||||
int ActivePlayerCount,
|
int ActivePlayerCount,
|
||||||
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
IReadOnlyList<PlayerViewItem> ActivePlayers,
|
||||||
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
IReadOnlyList<PlayerViewItem> WaitlistedPlayers,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
<a class="nav-brand" href="">
|
<a class="nav-brand" href="">
|
||||||
<span class="nav-brand-icon">🐢</span>
|
<img src="logo.png" alt="GM-Relay" class="nav-brand-icon" />
|
||||||
<span class="nav-brand-text">GM-Relay</span>
|
<span class="nav-brand-text">GM-Relay</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Переключить меню">
|
<button class="nav-toggle" @onclick="ToggleMenu" aria-label="Переключить меню">
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="nav-version">v1.10.2</div>
|
<div class="nav-version">v1.13.0</div>
|
||||||
</div>
|
</div>
|
||||||
</Authorized>
|
</Authorized>
|
||||||
<NotAuthorized>
|
<NotAuthorized>
|
||||||
@@ -79,4 +79,4 @@
|
|||||||
|
|
||||||
private void ToggleMenu() => isOpen = !isOpen;
|
private void ToggleMenu() => isOpen = !isOpen;
|
||||||
private void CloseMenu() => isOpen = false;
|
private void CloseMenu() => isOpen = false;
|
||||||
}
|
}
|
||||||
@@ -9,14 +9,17 @@
|
|||||||
|
|
||||||
.nav-brand {
|
.nav-brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
gap: 0.625rem;
|
gap: 0.75rem;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand-icon {
|
.nav-brand-icon {
|
||||||
font-size: 1.5rem;
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-brand-text {
|
.nav-brand-text {
|
||||||
@@ -195,6 +198,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-header {
|
.nav-header {
|
||||||
|
padding-left: 3.75rem;
|
||||||
padding-right: 0.75rem;
|
padding-right: 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<div class="login-page">
|
<div class="login-page">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-logo">🎲</div>
|
<img src="logo.png" alt="GM-Relay" class="login-logo" />
|
||||||
<h1 class="login-title">GM-Relay</h1>
|
<h1 class="login-title">GM-Relay</h1>
|
||||||
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
|
<p class="login-subtitle">Войдите через Telegram для управления игровыми сессиями</p>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
<div class="mini-app-page">
|
<div class="mini-app-page">
|
||||||
<div class="mini-app-auth-card" data-auth-status="@miniAppAuthStatus">
|
<div class="mini-app-auth-card" data-auth-status="@miniAppAuthStatus">
|
||||||
<div class="mini-app-logo">🎲</div>
|
<img src="logo.png" alt="GM-Relay" class="mini-app-logo" />
|
||||||
<h1>GM-Relay</h1>
|
<h1>GM-Relay</h1>
|
||||||
<p>@statusMessage</p>
|
<p>@statusMessage</p>
|
||||||
|
|
||||||
|
|||||||
@@ -938,7 +938,7 @@ public sealed class SessionService(
|
|||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers));
|
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers, batchJoinLink));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
@@ -1147,7 +1147,7 @@ public sealed class SessionService(
|
|||||||
},
|
},
|
||||||
transaction);
|
transaction);
|
||||||
|
|
||||||
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers));
|
renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers, template.JoinLink));
|
||||||
}
|
}
|
||||||
|
|
||||||
await transaction.CommitAsync();
|
await transaction.CommitAsync();
|
||||||
@@ -1245,7 +1245,7 @@ public sealed class SessionService(
|
|||||||
await using var conn = await dataSource.OpenConnectionAsync();
|
await using var conn = await dataSource.OpenConnectionAsync();
|
||||||
|
|
||||||
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
var sessions = (await conn.QueryAsync<SessionBatchDto>(
|
||||||
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
"SELECT id AS SessionId, scheduled_at AS ScheduledAt, status AS Status, max_players AS MaxPlayers, join_link AS JoinLink FROM sessions WHERE batch_id = @BatchId ORDER BY scheduled_at",
|
||||||
new { BatchId = batchId })).ToList();
|
new { BatchId = batchId })).ToList();
|
||||||
|
|
||||||
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
var participants = (await conn.QueryAsync<ParticipantBatchDto>(
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ public static class TelegramSessionBatchRenderer
|
|||||||
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
? $"👥 Места: {session.ActivePlayerCount}/{session.MaxPlayers.Value}\n"
|
||||||
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
: $"👥 Игроки ({session.ActivePlayerCount}):\n";
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(session.JoinLink))
|
||||||
|
{
|
||||||
|
messageText += $"🔗 <a href=\"{System.Net.WebUtility.HtmlEncode(session.JoinLink)}\">Ссылка на игру</a>\n";
|
||||||
|
}
|
||||||
|
|
||||||
if (session.ActivePlayers.Count > 0)
|
if (session.ActivePlayers.Count > 0)
|
||||||
{
|
{
|
||||||
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
messageText += string.Join("\n", session.ActivePlayers.Select(p =>
|
||||||
|
|||||||
@@ -877,8 +877,13 @@ select option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.login-logo {
|
.login-logo {
|
||||||
font-size: 2.5rem;
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
object-fit: contain;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-title {
|
.login-title {
|
||||||
@@ -919,8 +924,13 @@ select option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mini-app-logo {
|
.mini-app-logo {
|
||||||
font-size: 2.25rem;
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
object-fit: contain;
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini-app-auth-card h1 {
|
.mini-app-auth-card h1 {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
@@ -147,6 +147,7 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
string title,
|
string title,
|
||||||
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
IReadOnlyList<DateTimeOffset> scheduledTimes,
|
||||||
int? maxPlayers,
|
int? maxPlayers,
|
||||||
|
string joinLink,
|
||||||
SessionNotificationMode notificationMode)
|
SessionNotificationMode notificationMode)
|
||||||
{
|
{
|
||||||
Title = title;
|
Title = title;
|
||||||
@@ -156,7 +157,8 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
scheduledAt.UtcDateTime,
|
scheduledAt.UtcDateTime,
|
||||||
SessionStatus.Planned,
|
SessionStatus.Planned,
|
||||||
maxPlayers))
|
maxPlayers,
|
||||||
|
joinLink))
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,6 +175,7 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
parseResult.Title!,
|
parseResult.Title!,
|
||||||
parseResult.ScheduledTimes,
|
parseResult.ScheduledTimes,
|
||||||
parseResult.MaxPlayers,
|
parseResult.MaxPlayers,
|
||||||
|
parseResult.Link!,
|
||||||
notificationMode);
|
notificationMode);
|
||||||
|
|
||||||
scenario.RenderBatch();
|
scenario.RenderBatch();
|
||||||
@@ -318,7 +321,8 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
session.Id,
|
session.Id,
|
||||||
session.ScheduledAt,
|
session.ScheduledAt,
|
||||||
session.Status,
|
session.Status,
|
||||||
session.MaxPlayers))
|
session.MaxPlayers,
|
||||||
|
session.JoinLink))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
participants
|
participants
|
||||||
.Select(participant => new ParticipantBatchDto(
|
.Select(participant => new ParticipantBatchDto(
|
||||||
@@ -371,7 +375,8 @@ public sealed class TelegramLandingPromisesSmokeTests
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
DateTime ScheduledAt,
|
DateTime ScheduledAt,
|
||||||
string Status,
|
string Status,
|
||||||
int? MaxPlayers)
|
int? MaxPlayers,
|
||||||
|
string JoinLink)
|
||||||
{
|
{
|
||||||
public DateTime ScheduledAt { get; set; } = ScheduledAt;
|
public DateTime ScheduledAt { get; set; } = ScheduledAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
using GmRelay.Bot.Features.Confirmation.SendConfirmation;
|
||||||
|
using GmRelay.Bot.Features.Reminders.SendJoinLink;
|
||||||
|
using GmRelay.Bot.Features.Reminders.SendOneHourReminder;
|
||||||
|
using GmRelay.Bot.Infrastructure.Scheduling;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Infrastructure.Scheduling;
|
||||||
|
|
||||||
|
public sealed class SessionSchedulerServiceTests
|
||||||
|
{
|
||||||
|
private readonly FakeSystemClock _clock = new();
|
||||||
|
private readonly FakeSessionTriggerStore _store = new();
|
||||||
|
private readonly FakeSendConfirmationHandler _confirmationHandler = new();
|
||||||
|
private readonly FakeSendOneHourReminderHandler _oneHourHandler = new();
|
||||||
|
private readonly FakeSendJoinLinkHandler _joinLinkHandler = new();
|
||||||
|
|
||||||
|
private SessionSchedulerService CreateSut()
|
||||||
|
{
|
||||||
|
return new SessionSchedulerService(
|
||||||
|
_store,
|
||||||
|
_confirmationHandler,
|
||||||
|
_oneHourHandler,
|
||||||
|
_joinLinkHandler,
|
||||||
|
_clock,
|
||||||
|
NullLogger<SessionSchedulerService>.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenSessionNeedsConfirmation_CallsConfirmationHandler()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingConfirmation = [sessionId];
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
Assert.Equal(sessionId, _confirmationHandler.Calls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenSessionNeedsOneHourReminder_CallsOneHourHandler()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingOneHourReminder = [sessionId];
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(_oneHourHandler.Calls);
|
||||||
|
Assert.Equal(sessionId, _oneHourHandler.Calls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenSessionNeedsJoinLink_CallsJoinLinkHandler()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingJoinLink = [sessionId];
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(_joinLinkHandler.Calls);
|
||||||
|
Assert.Equal(sessionId, _joinLinkHandler.Calls[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenMultipleTriggers_AllHandlersCalled()
|
||||||
|
{
|
||||||
|
var confirmId = Guid.NewGuid();
|
||||||
|
var remindId = Guid.NewGuid();
|
||||||
|
var linkId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingConfirmation = [confirmId];
|
||||||
|
_store.SessionsNeedingOneHourReminder = [remindId];
|
||||||
|
_store.SessionsNeedingJoinLink = [linkId];
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
Assert.Single(_oneHourHandler.Calls);
|
||||||
|
Assert.Single(_joinLinkHandler.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenNoSessions_NothingCalled()
|
||||||
|
{
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Empty(_confirmationHandler.Calls);
|
||||||
|
Assert.Empty(_oneHourHandler.Calls);
|
||||||
|
Assert.Empty(_joinLinkHandler.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenConfirmationAlreadySent_DoesNotCallAgain()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingConfirmation = [sessionId];
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
|
||||||
|
// Simulate idempotency: store no longer returns the session
|
||||||
|
_store.SessionsNeedingConfirmation = [];
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(_confirmationHandler.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenHandlerThrows_LogsErrorAndContinues()
|
||||||
|
{
|
||||||
|
var goodId = Guid.NewGuid();
|
||||||
|
var badId = Guid.NewGuid();
|
||||||
|
var now = new DateTimeOffset(2026, 5, 15, 10, 0, 0, TimeSpan.Zero);
|
||||||
|
_clock.UtcNow = now;
|
||||||
|
_store.SessionsNeedingConfirmation = [badId, goodId];
|
||||||
|
_confirmationHandler.ThrowFor.Add(badId);
|
||||||
|
|
||||||
|
var sut = CreateSut();
|
||||||
|
await sut.TickAsync(CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(2, _confirmationHandler.Calls.Count);
|
||||||
|
Assert.Equal(badId, _confirmationHandler.Calls[0]);
|
||||||
|
Assert.Equal(goodId, _confirmationHandler.Calls[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TickAsync_WhenStoreThrows_LogsErrorAndDoesNotCrash()
|
||||||
|
{
|
||||||
|
_store.ThrowOnConfirmationQuery = true;
|
||||||
|
var sut = CreateSut();
|
||||||
|
|
||||||
|
var ex = await Record.ExceptionAsync(() => sut.TickAsync(CancellationToken.None));
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSendConfirmationHandler : ISendConfirmationHandler
|
||||||
|
{
|
||||||
|
public List<Guid> Calls { get; } = [];
|
||||||
|
public HashSet<Guid> ThrowFor { get; } = [];
|
||||||
|
|
||||||
|
public Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Calls.Add(sessionId);
|
||||||
|
if (ThrowFor.Contains(sessionId))
|
||||||
|
throw new InvalidOperationException($"Boom for {sessionId}");
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSendOneHourReminderHandler : ISendOneHourReminderHandler
|
||||||
|
{
|
||||||
|
public List<Guid> Calls { get; } = [];
|
||||||
|
|
||||||
|
public Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Calls.Add(sessionId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSendJoinLinkHandler : ISendJoinLinkHandler
|
||||||
|
{
|
||||||
|
public List<Guid> Calls { get; } = [];
|
||||||
|
|
||||||
|
public Task HandleAsync(Guid sessionId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Calls.Add(sessionId);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeSessionTriggerStore : ISessionTriggerStore
|
||||||
|
{
|
||||||
|
public List<Guid> SessionsNeedingConfirmation { get; set; } = [];
|
||||||
|
public List<Guid> SessionsNeedingOneHourReminder { get; set; } = [];
|
||||||
|
public List<Guid> SessionsNeedingJoinLink { get; set; } = [];
|
||||||
|
public bool ThrowOnConfirmationQuery { get; set; }
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Guid>> GetSessionsNeedingConfirmationAsync(
|
||||||
|
DateTimeOffset now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (ThrowOnConfirmationQuery)
|
||||||
|
throw new InvalidOperationException("Store boom");
|
||||||
|
return Task.FromResult<IReadOnlyList<Guid>>(SessionsNeedingConfirmation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Guid>> GetSessionsNeedingOneHourReminderAsync(
|
||||||
|
DateTimeOffset now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<Guid>>(SessionsNeedingOneHourReminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<IReadOnlyList<Guid>> GetSessionsNeedingJoinLinkAsync(
|
||||||
|
DateTimeOffset now, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return Task.FromResult<IReadOnlyList<Guid>>(SessionsNeedingJoinLink);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ public sealed class SessionBatchViewBuilderTests
|
|||||||
|
|
||||||
var sessions = new[]
|
var sessions = new[]
|
||||||
{
|
{
|
||||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game"),
|
||||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
|
||||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game")
|
||||||
};
|
};
|
||||||
var participants = new[]
|
var participants = new[]
|
||||||
{
|
{
|
||||||
@@ -38,7 +38,7 @@ public sealed class SessionBatchViewBuilderTests
|
|||||||
public void Build_ShouldCalculatePlayerCounts()
|
public void Build_ShouldCalculatePlayerCounts()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
|
||||||
var participants = new[]
|
var participants = new[]
|
||||||
{
|
{
|
||||||
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active),
|
||||||
@@ -59,7 +59,7 @@ public sealed class SessionBatchViewBuilderTests
|
|||||||
public void Build_ShouldIncludeActionsForActiveSessions()
|
public void Build_ShouldIncludeActionsForActiveSessions()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4) };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 4, "https://example.com/game") };
|
||||||
var participants = Array.Empty<ParticipantBatchDto>();
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
@@ -76,7 +76,7 @@ public sealed class SessionBatchViewBuilderTests
|
|||||||
public void Build_ShouldNotIncludeActionsForCancelledSessions()
|
public void Build_ShouldNotIncludeActionsForCancelledSessions()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") };
|
||||||
var participants = Array.Empty<ParticipantBatchDto>();
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
@@ -88,7 +88,7 @@ public sealed class SessionBatchViewBuilderTests
|
|||||||
public void Build_ShouldMarkWaitlistActionWhenFull()
|
public void Build_ShouldMarkWaitlistActionWhenFull()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
|
||||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||||
|
|
||||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
@@ -101,7 +101,7 @@ public sealed class SessionBatchViewBuilderTests
|
|||||||
public void Build_ShouldIncludePlayerUsernames()
|
public void Build_ShouldIncludePlayerUsernames()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null) };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, null, "https://example.com/game") };
|
||||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||||
|
|
||||||
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var result = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
|
|
||||||
var sessions = new[]
|
var sessions = new[]
|
||||||
{
|
{
|
||||||
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4),
|
new SessionBatchDto(secondSessionId, new DateTime(2026, 4, 27, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 4, "https://example.com/game2"),
|
||||||
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null),
|
new SessionBatchDto(cancelledSessionId, new DateTime(2026, 4, 28, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Cancelled, null, ""),
|
||||||
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2)
|
new SessionBatchDto(firstSessionId, new DateTime(2026, 4, 26, 18, 0, 0, DateTimeKind.Utc), SessionStatus.Planned, 2, "https://example.com/game1")
|
||||||
};
|
};
|
||||||
var participants = new[]
|
var participants = new[]
|
||||||
{
|
{
|
||||||
@@ -35,6 +35,9 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
Assert.Contains("Charlie", text);
|
Assert.Contains("Charlie", text);
|
||||||
Assert.Contains("Bob", text);
|
Assert.Contains("Bob", text);
|
||||||
Assert.Contains("Сессия отменена", text);
|
Assert.Contains("Сессия отменена", text);
|
||||||
|
Assert.Contains("Ссылка на игру", text);
|
||||||
|
Assert.Contains("https://example.com/game1", text);
|
||||||
|
Assert.Contains("https://example.com/game2", text);
|
||||||
|
|
||||||
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
var buttons = markup.InlineKeyboard.SelectMany(row => row).ToList();
|
||||||
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
|
Assert.Equal(4, buttons.Count); // 2 sessions x 2 buttons each
|
||||||
@@ -51,7 +54,7 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
public void Render_ShouldSkipButtonsForCancelledSessions()
|
public void Render_ShouldSkipButtonsForCancelledSessions()
|
||||||
{
|
{
|
||||||
var cancelledSessionId = Guid.NewGuid();
|
var cancelledSessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null) };
|
var sessions = new[] { new SessionBatchDto(cancelledSessionId, DateTime.UtcNow, SessionStatus.Cancelled, null, "") };
|
||||||
var participants = Array.Empty<ParticipantBatchDto>();
|
var participants = Array.Empty<ParticipantBatchDto>();
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
@@ -64,7 +67,7 @@ public sealed class TelegramSessionBatchRendererTests
|
|||||||
public void Render_ShouldShowWaitlistButtonWhenFull()
|
public void Render_ShouldShowWaitlistButtonWhenFull()
|
||||||
{
|
{
|
||||||
var sessionId = Guid.NewGuid();
|
var sessionId = Guid.NewGuid();
|
||||||
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1) };
|
var sessions = new[] { new SessionBatchDto(sessionId, DateTime.UtcNow, SessionStatus.Planned, 1, "https://example.com/game") };
|
||||||
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
var participants = new[] { new ParticipantBatchDto(sessionId, "Alice", "alice", ParticipantRegistrationStatus.Active) };
|
||||||
|
|
||||||
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
var view = SessionBatchViewBuilder.Build("Test", sessions, participants);
|
||||||
|
|||||||
@@ -244,6 +244,50 @@ public sealed class AuthorizedSessionServiceTests
|
|||||||
Assert.Empty(store.LogEntries);
|
Assert.Empty(store.LogEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSessionHistoryForGmAsync_ReturnsHistory_WhenSessionBelongsToOwnedGroup()
|
||||||
|
{
|
||||||
|
var gmId = 1001L;
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", gmId)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var history = await service.GetSessionHistoryForGmAsync(sessionId, gmId);
|
||||||
|
|
||||||
|
Assert.NotNull(history);
|
||||||
|
Assert.Empty(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSessionHistoryForGmAsync_ReturnsNull_WhenSessionBelongsToAnotherGm()
|
||||||
|
{
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var store = new FakeSessionStore(
|
||||||
|
groups:
|
||||||
|
[
|
||||||
|
new(groupId, 42, "Alpha", 2002L)
|
||||||
|
],
|
||||||
|
sessions:
|
||||||
|
[
|
||||||
|
new(sessionId, groupId, "Session A", DateTime.UtcNow, "Planned", "https://example.test/a", Guid.NewGuid(), 10, 42, 4, 1, 0)
|
||||||
|
]);
|
||||||
|
var service = new AuthorizedSessionService(store);
|
||||||
|
|
||||||
|
var history = await service.GetSessionHistoryForGmAsync(sessionId, 1001L);
|
||||||
|
|
||||||
|
Assert.Null(history);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession()
|
public async Task PromoteWaitlistedPlayerForGmAsync_PromotesOwnedSession()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user