refactor: extract remaining Telegram handlers to platform-neutral contracts
PR Checks / test-and-build (pull_request) Successful in 13m48s
PR Checks / test-and-build (pull_request) Successful in 13m48s
- Extract CreateSessionHandler, ListSessionsHandler, DeleteSessionHandler, ExportCalendarHandler, HandleRescheduleTimeInputHandler, HandleRescheduleVoteHandler to GmRelay.Shared - Add IPlatformMessenger methods: SendScheduleAsync, UpdateScheduleAsync, SendGroupMessageAsync with actions, CreateThreadAsync, DeleteThreadAsync - Rewrite Telegram Bot wrappers as thin adapters delegating to shared handlers - Rewrite DiscordRescheduleVoteHandler to use shared HandleRescheduleVoteHandler - Update UpdateRouter with explicit type aliases for ambiguous handler names - Add contract and source-inspection tests for extracted handlers - Bump version 3.1.1 → 3.2.0 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record CreateSessionCommand(
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string Title,
|
||||
string Link,
|
||||
IReadOnlyList<DateTimeOffset> ScheduledTimes,
|
||||
int? MaxPlayers,
|
||||
string? ImageReference);
|
||||
@@ -0,0 +1,157 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
internal sealed record SessionCreationGroupAccessDto(Guid GroupId, bool CanManage);
|
||||
|
||||
public sealed class CreateSessionHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<CreateSessionResult> HandleAsync(CreateSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
try
|
||||
{
|
||||
var platform = command.User.Platform.ToString();
|
||||
var externalUserId = command.User.ExternalUserId;
|
||||
var displayName = command.User.DisplayName;
|
||||
var externalUsername = command.User.ExternalUsername;
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO players (display_name, platform, external_user_id, external_username)
|
||||
VALUES (@Name, @Platform, @ExternalId, @Username)
|
||||
ON CONFLICT (platform, external_user_id)
|
||||
WHERE platform IS NOT NULL AND external_user_id IS NOT NULL
|
||||
DO UPDATE
|
||||
SET display_name = EXCLUDED.display_name,
|
||||
external_username = EXCLUDED.external_username;
|
||||
""",
|
||||
new { ExternalId = externalUserId, Name = displayName, Username = externalUsername },
|
||||
transaction);
|
||||
|
||||
var existingGroup = await connection.QuerySingleOrDefaultAsync<SessionCreationGroupAccessDto>(
|
||||
"""
|
||||
SELECT g.id AS GroupId,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = g.id
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalGmId
|
||||
) AS CanManage
|
||||
FROM game_groups g
|
||||
WHERE g.platform = @Platform
|
||||
AND g.external_group_id = @ExternalGroupId
|
||||
""",
|
||||
new { Platform = platform, ExternalGroupId = command.Group.ExternalGroupId, ExternalGmId = externalUserId },
|
||||
transaction);
|
||||
|
||||
Guid groupId;
|
||||
if (existingGroup is null)
|
||||
{
|
||||
groupId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO game_groups (name, platform, external_group_id, external_channel_id)
|
||||
VALUES (@ChatName, @Platform, @ExternalGroupId, @ExternalChannelId)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
{
|
||||
Platform = platform,
|
||||
ExternalGroupId = command.Group.ExternalGroupId,
|
||||
ExternalChannelId = command.Group.ExternalChannelId,
|
||||
ChatName = command.Group.DisplayName
|
||||
},
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO group_managers (group_id, player_id, role)
|
||||
SELECT @GroupId, p.id, @OwnerRole
|
||||
FROM players p
|
||||
WHERE p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalGmId
|
||||
ON CONFLICT (group_id, player_id) DO NOTHING
|
||||
""",
|
||||
new { GroupId = groupId, ExternalGmId = externalUserId, OwnerRole = GroupManagerRoleExtensions.OwnerValue },
|
||||
transaction);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!existingGroup.CanManage)
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
return new CreateSessionResult(
|
||||
false,
|
||||
"⛔ Только owner или co-GM этой группы может создавать игровые сессии.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
|
||||
groupId = existingGroup.GroupId;
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE game_groups
|
||||
SET name = @ChatName
|
||||
WHERE id = @GroupId
|
||||
""",
|
||||
new { ChatName = command.Group.DisplayName, GroupId = groupId },
|
||||
transaction);
|
||||
}
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var sessions = new List<SessionBatchDto>();
|
||||
var orderedTimes = command.ScheduledTimes.OrderBy(v => v).ToList();
|
||||
|
||||
foreach (var scheduledAt in orderedTimes)
|
||||
{
|
||||
var sessionId = await connection.ExecuteScalarAsync<Guid>(
|
||||
"""
|
||||
INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players)
|
||||
VALUES (@BatchId, @GroupId, @Title, @Link, @ScheduledAt, @Status, @MaxPlayers)
|
||||
RETURNING id;
|
||||
""",
|
||||
new
|
||||
{
|
||||
BatchId = batchId,
|
||||
GroupId = groupId,
|
||||
command.Title,
|
||||
Link = command.Link,
|
||||
ScheduledAt = scheduledAt,
|
||||
Status = SessionStatus.Planned,
|
||||
MaxPlayers = command.MaxPlayers
|
||||
},
|
||||
transaction);
|
||||
|
||||
sessions.Add(new SessionBatchDto(sessionId, scheduledAt.UtcDateTime, SessionStatus.Planned, command.MaxPlayers, command.Link));
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
var view = SessionBatchViewBuilder.Build(command.Title, sessions, Array.Empty<ParticipantBatchDto>());
|
||||
|
||||
return new CreateSessionResult(
|
||||
true,
|
||||
null,
|
||||
view,
|
||||
batchId,
|
||||
groupId,
|
||||
Array.Empty<string>());
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using GmRelay.Shared.Rendering;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.CreateSession;
|
||||
|
||||
public sealed record CreateSessionResult(
|
||||
bool Success,
|
||||
string? ErrorMessage,
|
||||
SessionBatchViewModel? View,
|
||||
Guid? BatchId,
|
||||
Guid? GroupId,
|
||||
IReadOnlyList<string> Warnings);
|
||||
@@ -0,0 +1,7 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||
|
||||
public sealed record ExportCalendarCommand(
|
||||
PlatformGroup Group,
|
||||
PlatformUser User);
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ExportCalendar;
|
||||
|
||||
internal sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt);
|
||||
|
||||
public sealed class ExportCalendarHandler(
|
||||
NpgsqlDataSource dataSource,
|
||||
IPlatformMessenger messenger,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
public async Task HandleAsync(ExportCalendarCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var sessions = await connection.QueryAsync<CalendarSessionDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt"
|
||||
+ " FROM sessions s"
|
||||
+ " JOIN game_groups g ON s.group_id = g.id"
|
||||
+ " WHERE g.platform = @Platform"
|
||||
+ " AND g.external_group_id = @ExternalGroupId"
|
||||
+ " AND s.status = @Planned"
|
||||
+ " AND s.scheduled_at > NOW()"
|
||||
+ " ORDER BY s.scheduled_at ASC",
|
||||
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId, Planned = SessionStatus.Planned });
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
|
||||
if (sessionsList.Count == 0)
|
||||
{
|
||||
await messenger.SendGroupMessageAsync(
|
||||
command.Group,
|
||||
"📭 У этой группы нет запланированных сессий для экспорта.",
|
||||
cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("BEGIN:VCALENDAR");
|
||||
sb.AppendLine("VERSION:2.0");
|
||||
sb.AppendLine("PRODID:-//GM-Relay//TTRPG Schedule//EN");
|
||||
|
||||
foreach (var s in sessionsList)
|
||||
{
|
||||
var dtStart = s.ScheduledAt.ToString("yyyyMMddTHHmmssZ");
|
||||
var dtEnd = s.ScheduledAt.AddHours(4).ToString("yyyyMMddTHHmmssZ");
|
||||
|
||||
sb.AppendLine("BEGIN:VEVENT");
|
||||
sb.AppendLine($"UID:{s.Id}@gmrelay");
|
||||
sb.AppendLine($"DTSTAMP:{DateTime.UtcNow:yyyyMMddTHHmmssZ}");
|
||||
sb.AppendLine($"DTSTART:{dtStart}");
|
||||
sb.AppendLine($"DTEND:{dtEnd}");
|
||||
sb.AppendLine($"SUMMARY:{s.Title}");
|
||||
sb.AppendLine("END:VEVENT");
|
||||
}
|
||||
|
||||
sb.AppendLine("END:VCALENDAR");
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(sb.ToString());
|
||||
|
||||
// Create calendar subscription
|
||||
string? subscriptionUrl = null;
|
||||
var baseUrl = configuration["Web:BaseUrl"];
|
||||
var senderId = command.User.ExternalUserId;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl) && !string.IsNullOrWhiteSpace(senderId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var groupId = await connection.QueryFirstOrDefaultAsync<Guid?>(
|
||||
@"SELECT id FROM game_groups WHERE platform = @Platform AND external_group_id = @ExternalGroupId",
|
||||
new { Platform = command.Group.Platform.ToString(), ExternalGroupId = command.Group.ExternalGroupId });
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
@"INSERT INTO calendar_subscriptions (id, token, user_platform, user_external_id, group_id, filter_type, created_at, expires_at)
|
||||
VALUES (gen_random_uuid(), @token, @userPlatform, @userExternalId, @groupId, @filterType, now(), NULL)",
|
||||
new { token, userPlatform = command.Group.Platform.ToString(), userExternalId = senderId, groupId, filterType = (int)CalendarSubscriptionFilter.SpecificGroup });
|
||||
|
||||
subscriptionUrl = $"{baseUrl.TrimEnd('/')}/calendar/{token}.ics";
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-critical: if subscription creation fails, still send the file
|
||||
}
|
||||
}
|
||||
|
||||
var actions = subscriptionUrl is not null
|
||||
? new[]
|
||||
{
|
||||
new PlatformMessageAction(
|
||||
"calendar-subscription",
|
||||
"🔗 Подписаться на календарь",
|
||||
subscriptionUrl)
|
||||
}
|
||||
: Array.Empty<PlatformMessageAction>();
|
||||
|
||||
await messenger.SendCalendarFileAsync(
|
||||
new PlatformCalendarFile(
|
||||
command.Group,
|
||||
"schedule.ics",
|
||||
bytes,
|
||||
"📅 <b>Ваш календарь игр!</b>\nОткройте файл на устройстве, чтобы добавить события в свой календарь.",
|
||||
actions),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
internal sealed record DeleteSessionInfoDto(
|
||||
string Title,
|
||||
Guid BatchId,
|
||||
Guid GroupId,
|
||||
bool CanManage,
|
||||
int? ThreadId,
|
||||
bool TopicCreatedByBot);
|
||||
|
||||
public sealed record DeleteSessionResult(
|
||||
bool Success,
|
||||
string? ReplyText,
|
||||
string? Title,
|
||||
Guid? GroupId,
|
||||
int? ThreadId,
|
||||
bool TopicCreatedByBot,
|
||||
int RemainingInTopic);
|
||||
|
||||
public sealed class DeleteSessionHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<DeleteSessionResult> HandleAsync(DeleteSessionCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
// 1. Fetch session and verify group manager.
|
||||
var session = await connection.QuerySingleOrDefaultAsync<DeleteSessionInfoDto>(
|
||||
"""
|
||||
SELECT s.title AS Title,
|
||||
s.batch_id AS BatchId,
|
||||
s.group_id AS GroupId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.topic_created_by_bot AS TopicCreatedByBot,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players p ON p.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
WHERE s.id = @SessionId
|
||||
""",
|
||||
new { command.SessionId, Platform = command.User.Platform.ToString(), ExternalUserId = command.User.ExternalUserId }, transaction);
|
||||
|
||||
if (session == null)
|
||||
{
|
||||
return new DeleteSessionResult(false, "Сессия не найдена.", null, null, null, false, 0);
|
||||
}
|
||||
|
||||
if (!session.CanManage)
|
||||
{
|
||||
return new DeleteSessionResult(false, "Только owner или co-GM может удалять сессию.", null, null, null, false, 0);
|
||||
}
|
||||
|
||||
// 2. Delete session
|
||||
await connection.ExecuteAsync("DELETE FROM sessions WHERE id = @Id", new { Id = command.SessionId }, transaction);
|
||||
|
||||
var remainingInTopic = session.ThreadId.HasValue
|
||||
? await connection.ExecuteScalarAsync<int>(
|
||||
"""
|
||||
SELECT COUNT(*)
|
||||
FROM sessions
|
||||
WHERE group_id = @GroupId
|
||||
AND thread_id = @ThreadId
|
||||
""",
|
||||
new { session.GroupId, ThreadId = session.ThreadId.Value },
|
||||
transaction)
|
||||
: 0;
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
return new DeleteSessionResult(
|
||||
true,
|
||||
"Сессия удалена!",
|
||||
session.Title,
|
||||
session.GroupId,
|
||||
session.ThreadId,
|
||||
session.TopicCreatedByBot,
|
||||
remainingInTopic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
public sealed record ListSessionsCommand(
|
||||
PlatformGroup Group,
|
||||
PlatformUser User);
|
||||
|
||||
public sealed record DeleteSessionCommand(
|
||||
Guid SessionId,
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
@@ -0,0 +1,57 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.ListSessions;
|
||||
|
||||
public sealed record SessionListItemDto(Guid Id, string Title, DateTime ScheduledAt, string Status, int? MaxPlayers, int PlayerCount, int WaitlistCount, bool CanManage);
|
||||
|
||||
public sealed record SessionListResult(
|
||||
IReadOnlyList<SessionListItemDto> Sessions,
|
||||
bool CanManage);
|
||||
|
||||
public sealed class ListSessionsHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<SessionListResult> HandleAsync(ListSessionsCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(cancellationToken);
|
||||
|
||||
var sessions = await connection.QueryAsync<SessionListItemDto>(
|
||||
@"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt, s.status as Status, s.max_players as MaxPlayers,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Active) as PlayerCount,
|
||||
COUNT(sp.id) FILTER (WHERE sp.is_gm = false AND sp.registration_status = @Waitlisted) as WaitlistCount,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.platform = @Platform
|
||||
AND manager_player.external_user_id = @ExternalUserId
|
||||
) AS CanManage
|
||||
FROM sessions s
|
||||
JOIN game_groups g ON s.group_id = g.id
|
||||
LEFT JOIN session_participants sp ON s.id = sp.session_id
|
||||
WHERE g.platform = @Platform
|
||||
AND g.external_group_id = @ExternalGroupId
|
||||
AND s.status != @Cancelled
|
||||
AND s.scheduled_at > NOW()
|
||||
GROUP BY s.id, s.title, s.scheduled_at, s.status, s.max_players, s.group_id
|
||||
ORDER BY s.scheduled_at ASC",
|
||||
new
|
||||
{
|
||||
Platform = command.Group.Platform.ToString(),
|
||||
ExternalGroupId = command.Group.ExternalGroupId,
|
||||
ExternalUserId = command.User.ExternalUserId,
|
||||
Cancelled = SessionStatus.Cancelled,
|
||||
Active = ParticipantRegistrationStatus.Active,
|
||||
Waitlisted = ParticipantRegistrationStatus.Waitlisted
|
||||
});
|
||||
|
||||
var sessionsList = sessions.ToList();
|
||||
var canManage = sessionsList.Count > 0 && sessionsList.First().CanManage;
|
||||
|
||||
return new SessionListResult(sessionsList, canManage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
internal sealed record AwaitingProposalDto(
|
||||
Guid Id,
|
||||
Guid SessionId,
|
||||
string Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
Guid BatchId,
|
||||
int? BatchMessageId,
|
||||
string ExternalGroupId,
|
||||
int? ThreadId,
|
||||
string NotificationMode);
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
using GmRelay.Shared.Platform;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleTimeInputCommand(
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string Text);
|
||||
|
||||
public sealed record HandleRescheduleVoteCommand(
|
||||
Guid OptionId,
|
||||
PlatformUser User,
|
||||
PlatformGroup Group,
|
||||
string InteractionId,
|
||||
PlatformMessageRef ScheduleMessage);
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using GmRelay.Shared.Rendering;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed class HandleRescheduleTimeInputHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<HandleRescheduleTimeInputResult> HandleAsync(
|
||||
HandleRescheduleTimeInputCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
|
||||
var platform = command.User.Platform.ToString();
|
||||
var externalGmId = command.User.ExternalUserId;
|
||||
var externalGroupId = command.Group.ExternalGroupId;
|
||||
|
||||
var proposal = await connection.QuerySingleOrDefaultAsync<AwaitingProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id, rp.session_id AS SessionId, s.title AS Title, s.scheduled_at AS CurrentScheduledAt,
|
||||
s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId,
|
||||
g.external_group_id AS ExternalGroupId,
|
||||
s.thread_id AS ThreadId,
|
||||
s.notification_mode AS NotificationMode
|
||||
FROM reschedule_proposals rp
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
JOIN game_groups g ON g.id = s.group_id
|
||||
WHERE rp.proposed_by_external_user_id = @ExternalGmId
|
||||
AND rp.status = 'AwaitingTime'
|
||||
AND g.platform = @Platform
|
||||
AND g.external_group_id = @ExternalGroupId
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM group_managers gm
|
||||
JOIN players manager_player ON manager_player.id = gm.player_id
|
||||
WHERE gm.group_id = s.group_id
|
||||
AND manager_player.platform = @Platform
|
||||
AND manager_player.external_user_id = @ExternalGmId
|
||||
)
|
||||
ORDER BY rp.created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
new { ExternalGmId = externalGmId, Platform = platform, ExternalGroupId = externalGroupId });
|
||||
|
||||
if (proposal is null)
|
||||
return new HandleRescheduleTimeInputResult(false, false, null, null, null, null, [], [], [], null, default, null);
|
||||
|
||||
if (!RescheduleVotingInput.TryParse(command.Text, DateTimeOffset.UtcNow, out var votingInput, out var parseError))
|
||||
{
|
||||
return new HandleRescheduleTimeInputResult(
|
||||
true, false, parseError, null, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, null);
|
||||
}
|
||||
|
||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||
"""
|
||||
SELECT p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_username AS TelegramUsername,
|
||||
p.external_user_id::BIGINT AS TelegramId
|
||||
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
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active })).ToList();
|
||||
|
||||
if (participants.Count == 0)
|
||||
{
|
||||
var newTime = votingInput.Options[0];
|
||||
var view = await RescheduleImmediatelyAsync(connection, proposal, newTime, ct);
|
||||
var replyText =
|
||||
$"""✅ Сессия «{proposal.Title}» перенесена!\n\n📅 Новое время: <b>{newTime.ToOffset(TimeSpan.FromHours(3)).ToString("d MMMM yyyy, HH:mm", System.Globalization.CultureInfo.GetCultureInfo("ru-RU"))}</b> (МСК)\n\n<i>Участников нет — голосование не требуется.</i>""";
|
||||
return new HandleRescheduleTimeInputResult(
|
||||
true, true, replyText, view, null, null, [], [], [], proposal.Title, proposal.CurrentScheduledAt, proposal.BatchMessageId);
|
||||
}
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
var options = votingInput.Options
|
||||
.Select((proposedAt, index) => new RescheduleOptionDto(
|
||||
Guid.NewGuid(),
|
||||
index + 1,
|
||||
proposedAt))
|
||||
.ToList();
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE reschedule_proposals
|
||||
SET voting_deadline_at = @Deadline, status = 'Voting', vote_chat_id = @VoteChatId
|
||||
WHERE id = @Id
|
||||
""",
|
||||
new { votingInput.Deadline, VoteChatId = externalGroupId, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
foreach (var option in options)
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_options (id, proposal_id, proposed_at, display_order)
|
||||
VALUES (@OptionId, @ProposalId, @ProposedAt, @DisplayOrder)
|
||||
""",
|
||||
new
|
||||
{
|
||||
option.OptionId,
|
||||
ProposalId = proposal.Id,
|
||||
option.ProposedAt,
|
||||
option.DisplayOrder
|
||||
},
|
||||
transaction);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
return new HandleRescheduleTimeInputResult(
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
proposal.Id,
|
||||
votingInput.Deadline,
|
||||
options,
|
||||
participants,
|
||||
[],
|
||||
proposal.Title,
|
||||
proposal.CurrentScheduledAt,
|
||||
null);
|
||||
}
|
||||
|
||||
private static async Task<SessionBatchViewModel?> RescheduleImmediatelyAsync(
|
||||
NpgsqlConnection connection,
|
||||
AwaitingProposalDto proposal,
|
||||
DateTimeOffset newTime,
|
||||
CancellationToken ct)
|
||||
{
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
UPDATE sessions
|
||||
SET scheduled_at = @NewTime,
|
||||
status = @Status,
|
||||
confirmation_message_id = NULL,
|
||||
confirmation_sent_at = NULL,
|
||||
one_hour_reminder_processed_at = NULL,
|
||||
updated_at = now()
|
||||
WHERE id = @SessionId
|
||||
""",
|
||||
new { NewTime = newTime, proposal.SessionId, Status = SessionStatus.Planned },
|
||||
transaction);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"UPDATE reschedule_proposals SET proposed_at = @NewTime, status = 'Approved' WHERE id = @Id",
|
||||
new { NewTime = newTime, Id = proposal.Id },
|
||||
transaction);
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
var batchSessions = (await connection.QueryAsync<SessionBatchDto>(
|
||||
"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();
|
||||
|
||||
var batchParticipants = (await connection.QueryAsync<ParticipantBatchDto>(
|
||||
"""
|
||||
SELECT sp.session_id AS SessionId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_username AS TelegramUsername,
|
||||
sp.registration_status AS RegistrationStatus
|
||||
FROM session_participants sp
|
||||
JOIN players p ON sp.player_id = p.id
|
||||
JOIN sessions s ON sp.session_id = s.id
|
||||
WHERE s.batch_id = @BatchId AND sp.is_gm = false
|
||||
ORDER BY sp.registration_status ASC, sp.created_at ASC, sp.responded_at ASC, p.created_at ASC
|
||||
""",
|
||||
new { proposal.BatchId })).ToList();
|
||||
|
||||
return SessionBatchViewBuilder.Build(proposal.Title, batchSessions, batchParticipants);
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
using GmRelay.Shared.Rendering;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleTimeInputResult(
|
||||
bool Handled,
|
||||
bool IsRescheduledImmediately,
|
||||
string? ReplyText,
|
||||
SessionBatchViewModel? UpdatedView,
|
||||
Guid? ProposalId,
|
||||
DateTimeOffset? VotingDeadlineAt,
|
||||
IReadOnlyList<RescheduleOptionDto> Options,
|
||||
IReadOnlyList<VoteParticipantDto> Participants,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> Votes,
|
||||
string? Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
int? BatchMessageId);
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
using Dapper;
|
||||
using GmRelay.Shared.Domain;
|
||||
using GmRelay.Shared.Platform;
|
||||
using Npgsql;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed class HandleRescheduleVoteHandler(
|
||||
NpgsqlDataSource dataSource)
|
||||
{
|
||||
public async Task<HandleRescheduleVoteResult> HandleAsync(HandleRescheduleVoteCommand command, CancellationToken ct)
|
||||
{
|
||||
await using var connection = await dataSource.OpenConnectionAsync(ct);
|
||||
await using var transaction = await connection.BeginTransactionAsync(ct);
|
||||
|
||||
var proposal = await connection.QuerySingleOrDefaultAsync<VoteProposalDto>(
|
||||
"""
|
||||
SELECT rp.id AS Id,
|
||||
rp.session_id AS SessionId,
|
||||
rp.voting_deadline_at AS VotingDeadlineAt,
|
||||
s.title AS Title,
|
||||
s.scheduled_at AS CurrentScheduledAt
|
||||
FROM reschedule_options ro
|
||||
JOIN reschedule_proposals rp ON rp.id = ro.proposal_id
|
||||
JOIN sessions s ON s.id = rp.session_id
|
||||
WHERE ro.id = @OptionId AND rp.status = 'Voting'
|
||||
""",
|
||||
new { command.OptionId },
|
||||
transaction);
|
||||
|
||||
if (proposal is null)
|
||||
{
|
||||
return new HandleRescheduleVoteResult(
|
||||
false,
|
||||
"Голосование уже завершено или не найдено.",
|
||||
null, null, null, default, default,
|
||||
Array.Empty<VoteParticipantDto>(),
|
||||
Array.Empty<RescheduleOptionDto>(),
|
||||
Array.Empty<RescheduleOptionVoteDto>());
|
||||
}
|
||||
|
||||
if (proposal.VotingDeadlineAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return new HandleRescheduleVoteResult(
|
||||
false,
|
||||
"Дедлайн уже прошёл. Результаты скоро будут применены.",
|
||||
null, null, null, default, default,
|
||||
Array.Empty<VoteParticipantDto>(),
|
||||
Array.Empty<RescheduleOptionDto>(),
|
||||
Array.Empty<RescheduleOptionVoteDto>());
|
||||
}
|
||||
|
||||
var playerId = await connection.ExecuteScalarAsync<Guid?>(
|
||||
"""
|
||||
SELECT p.id
|
||||
FROM session_participants sp
|
||||
JOIN players p ON p.id = sp.player_id
|
||||
WHERE sp.session_id = @SessionId
|
||||
AND p.platform = @Platform
|
||||
AND p.external_user_id = @ExternalUserId
|
||||
AND sp.is_gm = false
|
||||
AND sp.registration_status = @Active
|
||||
""",
|
||||
new
|
||||
{
|
||||
proposal.SessionId,
|
||||
Platform = command.User.Platform.ToString(),
|
||||
ExternalUserId = command.User.ExternalUserId,
|
||||
Active = ParticipantRegistrationStatus.Active
|
||||
},
|
||||
transaction);
|
||||
|
||||
if (playerId is null)
|
||||
{
|
||||
return new HandleRescheduleVoteResult(
|
||||
false,
|
||||
"Вы не являетесь участником этой сессии.",
|
||||
null, null, null, default, default,
|
||||
Array.Empty<VoteParticipantDto>(),
|
||||
Array.Empty<RescheduleOptionDto>(),
|
||||
Array.Empty<RescheduleOptionVoteDto>());
|
||||
}
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO reschedule_option_votes (proposal_id, player_id, option_id)
|
||||
VALUES (@ProposalId, @PlayerId, @OptionId)
|
||||
ON CONFLICT (proposal_id, player_id) DO UPDATE
|
||||
SET option_id = EXCLUDED.option_id,
|
||||
voted_at = now()
|
||||
""",
|
||||
new
|
||||
{
|
||||
ProposalId = proposal.Id,
|
||||
PlayerId = playerId.Value,
|
||||
command.OptionId
|
||||
},
|
||||
transaction);
|
||||
|
||||
var participants = (await connection.QueryAsync<VoteParticipantDto>(
|
||||
"""
|
||||
SELECT p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_username AS TelegramUsername,
|
||||
p.external_user_id AS TelegramId
|
||||
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
|
||||
ORDER BY p.display_name
|
||||
""",
|
||||
new { proposal.SessionId, Active = ParticipantRegistrationStatus.Active },
|
||||
transaction)).ToList();
|
||||
|
||||
var options = (await connection.QueryAsync<RescheduleOptionDto>(
|
||||
"""
|
||||
SELECT id AS OptionId,
|
||||
display_order AS DisplayOrder,
|
||||
proposed_at AS ProposedAt
|
||||
FROM reschedule_options
|
||||
WHERE proposal_id = @ProposalId
|
||||
ORDER BY display_order
|
||||
""",
|
||||
new { ProposalId = proposal.Id },
|
||||
transaction)).ToList();
|
||||
|
||||
var votes = (await connection.QueryAsync<RescheduleOptionVoteDto>(
|
||||
"""
|
||||
SELECT rov.option_id AS OptionId,
|
||||
p.id AS PlayerId,
|
||||
p.display_name AS DisplayName,
|
||||
p.external_username AS TelegramUsername
|
||||
FROM reschedule_option_votes rov
|
||||
JOIN players p ON p.id = rov.player_id
|
||||
WHERE rov.proposal_id = @ProposalId
|
||||
ORDER BY rov.voted_at, p.display_name
|
||||
""",
|
||||
new { ProposalId = proposal.Id },
|
||||
transaction)).ToList();
|
||||
|
||||
await transaction.CommitAsync(ct);
|
||||
|
||||
return new HandleRescheduleVoteResult(
|
||||
true,
|
||||
"Ваш голос учтён. До дедлайна его можно изменить.",
|
||||
proposal.Id,
|
||||
proposal.SessionId,
|
||||
proposal.Title,
|
||||
proposal.CurrentScheduledAt,
|
||||
proposal.VotingDeadlineAt,
|
||||
participants,
|
||||
options,
|
||||
votes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
namespace GmRelay.Shared.Features.Sessions.RescheduleSession;
|
||||
|
||||
public sealed record HandleRescheduleVoteResult(
|
||||
bool Success,
|
||||
string? ReplyText,
|
||||
Guid? ProposalId,
|
||||
Guid? SessionId,
|
||||
string? Title,
|
||||
DateTime CurrentScheduledAt,
|
||||
DateTimeOffset VotingDeadlineAt,
|
||||
IReadOnlyList<VoteParticipantDto> Participants,
|
||||
IReadOnlyList<RescheduleOptionDto> Options,
|
||||
IReadOnlyList<RescheduleOptionVoteDto> Votes);
|
||||
@@ -2,7 +2,23 @@ namespace GmRelay.Shared.Platform;
|
||||
|
||||
public interface IPlatformMessenger
|
||||
{
|
||||
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||
Task SendGroupMessageAsync(PlatformGroup group, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support messages with actions.");
|
||||
|
||||
Task UpdateGroupMessageAsync(PlatformMessageRef messageRef, string htmlText, IReadOnlyList<PlatformMessageAction> actions, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support message updates with actions.");
|
||||
|
||||
Task<PlatformMessageRef> CreateThreadAsync(PlatformGroup group, string title, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support thread creation.");
|
||||
|
||||
Task DeleteThreadAsync(PlatformGroup group, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support thread deletion.");
|
||||
|
||||
Task DeleteMessageAsync(PlatformMessageRef messageRef, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support message deletion.");
|
||||
|
||||
Task<PlatformMessageRef> SendScheduleAsync(PlatformScheduleMessage message, CancellationToken ct) =>
|
||||
throw new NotSupportedException("This platform messenger does not support schedule messages.");
|
||||
|
||||
Task UpdateScheduleAsync(PlatformScheduleMessage message, CancellationToken ct);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user