using Dapper; using GmRelay.Shared.Domain; using GmRelay.Shared.Rendering; using Npgsql; using Telegram.Bot; namespace GmRelay.Web.Services; public sealed record WebGameGroup(Guid Id, long TelegramChatId, string Name, long GmTelegramId); public sealed record WebSession( Guid Id, Guid GroupId, string Title, DateTime ScheduledAt, string Status, string JoinLink, Guid BatchId, int? BatchMessageId, long TelegramChatId, int? MaxPlayers, int ActivePlayerCount, int WaitlistedPlayerCount); internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); internal sealed record WebBatchInfo( Guid BatchId, Guid GroupId, string Title, string JoinLink, long TelegramChatId, int? BatchMessageId, int? ThreadId); internal sealed record WebBatchSessionRow( Guid Id, Guid GroupId, string Title, string JoinLink, DateTime ScheduledAt, string Status, int? MaxPlayers, int? BatchMessageId, long TelegramChatId, int? ThreadId); public sealed class SessionService( NpgsqlDataSource dataSource, ITelegramBotClient bot, ILogger logger) : ISessionStore { public async Task> GetGroupsForGmAsync(long gmId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( "SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE gm_telegram_id = @GmId", new { GmId = gmId })).ToList(); } public async Task GetGroupAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( "SELECT id, telegram_chat_id AS TelegramChatId, name, gm_telegram_id AS GmTelegramId FROM game_groups WHERE id = @GroupId", new { GroupId = groupId }); } public async Task> GetUpcomingSessionsAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Active ) active_counts ON true LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Waitlisted ) waitlist_counts ON true WHERE s.group_id = @GroupId AND s.scheduled_at > now() - interval '4 hours' ORDER BY s.scheduled_at", new { GroupId = groupId, Active = ParticipantRegistrationStatus.Active, Waitlisted = ParticipantRegistrationStatus.Waitlisted })).ToList(); } public async Task GetSessionAsync(Guid sessionId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, COALESCE(active_counts.count, 0)::int AS ActivePlayerCount, COALESCE(waitlist_counts.count, 0)::int AS WaitlistedPlayerCount FROM sessions s JOIN game_groups g ON g.id = s.group_id LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Active ) active_counts ON true LEFT JOIN LATERAL ( SELECT COUNT(*) AS count FROM session_participants sp WHERE sp.session_id = s.id AND sp.is_gm = false AND sp.registration_status = @Waitlisted ) waitlist_counts ON true WHERE s.id = @SessionId", new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active, Waitlisted = ParticipantRegistrationStatus.Waitlisted }); } public async Task GetBatchAsync(Guid batchId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( """ SELECT s.batch_id AS Id, s.group_id AS GroupId, (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title, (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, MIN(s.scheduled_at) AS FirstScheduledAt, MAX(s.scheduled_at) AS LastScheduledAt, COUNT(*)::int AS SessionCount FROM sessions s WHERE s.batch_id = @BatchId GROUP BY s.batch_id, s.group_id """, new { BatchId = batchId }); } public async Task UpdateSessionAsync(Guid sessionId, Guid groupId, string title, DateTime scheduledAt, string joinLink, int? maxPlayers) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var oldSession = await conn.QuerySingleOrDefaultAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, 0 AS ActivePlayerCount, 0 AS WaitlistedPlayerCount FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @Id AND s.group_id = @GroupId", new { Id = sessionId, GroupId = groupId }, transaction); if (oldSession is null) { throw new SessionAccessDeniedException(sessionId, 0); } var updatedRows = await conn.ExecuteAsync( @"UPDATE sessions SET title = @Title, scheduled_at = @ScheduledAt, join_link = @JoinLink, max_players = @MaxPlayers, updated_at = now() WHERE id = @Id AND group_id = @GroupId", new { Id = sessionId, GroupId = groupId, Title = title, ScheduledAt = scheduledAt, JoinLink = joinLink, MaxPlayers = maxPlayers }, transaction); if (updatedRows == 0) { throw new SessionAccessDeniedException(sessionId, 0); } await conn.ExecuteAsync( "UPDATE sessions SET title = @Title WHERE batch_id = @BatchId", new { Title = title, BatchId = oldSession.BatchId }, transaction); await transaction.CommitAsync(); var timeChanged = oldSession.ScheduledAt != scheduledAt; var notification = $"🔄 Мастер обновил игру!\n\n" + $"📌 {System.Net.WebUtility.HtmlEncode(title)}\n" + $"📅 Время: {scheduledAt.FormatMoscow()} (МСК)" + (timeChanged ? " (изменено)" : "") + "\n" + $"👥 Мест: {(maxPlayers.HasValue ? maxPlayers.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : "без лимита")}"; await bot.SendMessage(oldSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); if (oldSession.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(oldSession.BatchId, oldSession.TelegramChatId, oldSession.BatchMessageId.Value, title); } } public async Task PromoteWaitlistedPlayerAsync(Guid sessionId, Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var session = await conn.QuerySingleOrDefaultAsync( @"SELECT s.id, s.group_id AS GroupId, s.title, s.scheduled_at AS ScheduledAt, s.status, s.join_link AS JoinLink, s.batch_id AS BatchId, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.max_players AS MaxPlayers, 0 AS ActivePlayerCount, 0 AS WaitlistedPlayerCount FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.id = @SessionId AND s.group_id = @GroupId FOR UPDATE", new { SessionId = sessionId, GroupId = groupId }, transaction); if (session is null) { throw new SessionAccessDeniedException(sessionId, 0); } var activeParticipants = await conn.ExecuteScalarAsync( """ SELECT COUNT(*) FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Active """, new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active }, transaction); var waitlistedParticipants = await conn.ExecuteScalarAsync( """ SELECT COUNT(*) FROM session_participants WHERE session_id = @SessionId AND is_gm = false AND registration_status = @Waitlisted """, new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, transaction); if (!SessionCapacityRules.CanPromoteWaitlistedPlayer(session.MaxPlayers, activeParticipants, waitlistedParticipants)) { throw new InvalidOperationException(waitlistedParticipants == 0 ? "Лист ожидания пуст." : "Нет свободных мест для повышения игрока."); } var promoted = await conn.QuerySingleAsync( """ SELECT sp.id AS ParticipantRowId, p.display_name AS DisplayName FROM session_participants sp JOIN players p ON p.id = sp.player_id WHERE sp.session_id = @SessionId AND sp.is_gm = false AND sp.registration_status = @Waitlisted ORDER BY sp.created_at ASC, sp.id ASC LIMIT 1 FOR UPDATE OF sp """, new { SessionId = sessionId, Waitlisted = ParticipantRegistrationStatus.Waitlisted }, transaction); await conn.ExecuteAsync( """ UPDATE session_participants SET registration_status = @Active, rsvp_status = @Pending, responded_at = NULL WHERE id = @ParticipantRowId """, new { promoted.ParticipantRowId, Active = ParticipantRegistrationStatus.Active, Pending = RsvpStatus.Pending }, transaction); await transaction.CommitAsync(); await bot.SendMessage( session.TelegramChatId, $"⬆️ {System.Net.WebUtility.HtmlEncode(promoted.DisplayName)} переведен(а) из листа ожидания в основной состав «{System.Net.WebUtility.HtmlEncode(session.Title)}».", parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); if (session.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(session.BatchId, session.TelegramChatId, session.BatchMessageId.Value, session.Title); } } public async Task UpdateBatchDetailsAsync(Guid batchId, Guid groupId, string title, string joinLink) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var batch = await GetBatchInfoAsync(conn, batchId, groupId, transaction); if (batch is null) { throw new SessionAccessDeniedException(batchId, 0); } var updatedRows = await conn.ExecuteAsync( """ UPDATE sessions SET title = @Title, join_link = @JoinLink, updated_at = now() WHERE batch_id = @BatchId AND group_id = @GroupId """, new { BatchId = batchId, GroupId = groupId, Title = title, JoinLink = joinLink }, transaction); if (updatedRows == 0) { throw new SessionAccessDeniedException(batchId, 0); } await transaction.CommitAsync(); if (batch.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(batchId, batch.TelegramChatId, batch.BatchMessageId.Value, title); } } public async Task RescheduleBatchAsync(Guid batchId, Guid groupId, DateTime firstScheduledAt, int intervalDays) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var batchSessions = (await conn.QueryAsync( """ SELECT s.id AS Id, s.group_id AS GroupId, s.title AS Title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt, s.status AS Status, s.max_players AS MaxPlayers, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.thread_id AS ThreadId FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId AND s.group_id = @GroupId ORDER BY s.scheduled_at FOR UPDATE """, new { BatchId = batchId, GroupId = groupId }, transaction)).ToList(); if (batchSessions.Count == 0) { throw new SessionAccessDeniedException(batchId, 0); } var newSchedule = BatchSchedulePlanner.BuildFixedIntervalSchedule( batchSessions.Select(session => session.ScheduledAt), firstScheduledAt, intervalDays); for (var index = 0; index < batchSessions.Count; index++) { await conn.ExecuteAsync( """ UPDATE sessions SET scheduled_at = @ScheduledAt, updated_at = now() WHERE id = @SessionId """, new { SessionId = batchSessions[index].Id, ScheduledAt = newSchedule[index] }, transaction); } await transaction.CommitAsync(); var firstSession = batchSessions[0]; if (firstSession.BatchMessageId.HasValue) { await TryUpdateBatchMessageAsync(batchId, firstSession.TelegramChatId, firstSession.BatchMessageId.Value, firstSession.Title); } var notification = $"🔄 Мастер обновил расписание пачки\n\n" + $"📌 {System.Net.WebUtility.HtmlEncode(firstSession.Title)}\n" + $"🗓 Новое начало: {firstScheduledAt.FormatMoscow()} (МСК)\n" + $"↔️ Шаг: {intervalDays} дн."; await bot.SendMessage(firstSession.TelegramChatId, notification, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); } public async Task CloneBatchAsync(Guid batchId, Guid groupId, BatchCloneInterval interval) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var sourceSessions = (await conn.QueryAsync( """ SELECT s.id AS Id, s.group_id AS GroupId, s.title AS Title, s.join_link AS JoinLink, s.scheduled_at AS ScheduledAt, s.status AS Status, s.max_players AS MaxPlayers, s.batch_message_id AS BatchMessageId, g.telegram_chat_id AS TelegramChatId, s.thread_id AS ThreadId FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId AND s.group_id = @GroupId ORDER BY s.scheduled_at FOR UPDATE """, new { BatchId = batchId, GroupId = groupId }, transaction)).ToList(); if (sourceSessions.Count == 0) { throw new SessionAccessDeniedException(batchId, 0); } var newBatchId = Guid.NewGuid(); var batchTitle = sourceSessions[0].Title; var batchJoinLink = sourceSessions[0].JoinLink; var chatId = sourceSessions[0].TelegramChatId; var threadId = sourceSessions[0].ThreadId; var renderedSessions = new List(); foreach (var sourceSession in sourceSessions) { var scheduledAt = BatchSchedulePlanner.ShiftForClone(sourceSession.ScheduledAt, interval); var sessionId = await conn.ExecuteScalarAsync( """ INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, thread_id, max_players) VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers) RETURNING id """, new { BatchId = newBatchId, sourceSession.GroupId, Title = batchTitle, JoinLink = batchJoinLink, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, ThreadId = threadId, sourceSession.MaxPlayers }, transaction); renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, sourceSession.MaxPlayers)); } await transaction.CommitAsync(); var renderResult = SessionBatchRenderer.Render(batchTitle, renderedSessions, Array.Empty()); var batchMessage = await bot.SendMessage( chatId: chatId, messageThreadId: threadId, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup); await conn.ExecuteAsync( "UPDATE sessions SET batch_message_id = @MessageId WHERE batch_id = @BatchId", new { MessageId = batchMessage.MessageId, BatchId = newBatchId }); return new WebSessionBatch( newBatchId, groupId, batchTitle, batchJoinLink, renderedSessions.Min(session => session.ScheduledAt), renderedSessions.Max(session => session.ScheduledAt), renderedSessions.Count); } private async Task TryUpdateBatchMessageAsync(Guid batchId, long chatId, int messageId, string title) { try { await using var conn = await dataSource.OpenConnectionAsync(); var sessions = (await conn.QueryAsync( "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", new { BatchId = batchId })).ToList(); var participants = (await conn.QueryAsync( @"SELECT sp.session_id AS SessionId, p.display_name AS DisplayName, p.telegram_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 { BatchId = batchId })).ToList(); var renderResult = SessionBatchRenderer.Render(title, sessions, participants); await bot.EditMessageText( chatId: chatId, messageId: messageId, text: renderResult.Text, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html, replyMarkup: renderResult.Markup); } catch (Exception ex) { logger.LogWarning(ex, "Failed to update batch message {MessageId} in chat {ChatId}", messageId, chatId); } } private static async Task GetBatchInfoAsync( Npgsql.NpgsqlConnection conn, Guid batchId, Guid groupId, Npgsql.NpgsqlTransaction transaction) { return await conn.QuerySingleOrDefaultAsync( """ SELECT s.batch_id AS BatchId, s.group_id AS GroupId, (array_agg(s.title ORDER BY s.scheduled_at))[1] AS Title, (array_agg(s.join_link ORDER BY s.scheduled_at))[1] AS JoinLink, g.telegram_chat_id AS TelegramChatId, (array_agg(s.batch_message_id ORDER BY s.scheduled_at))[1] AS BatchMessageId, (array_agg(s.thread_id ORDER BY s.scheduled_at))[1] AS ThreadId FROM sessions s JOIN game_groups g ON g.id = s.group_id WHERE s.batch_id = @BatchId AND s.group_id = @GroupId GROUP BY s.batch_id, s.group_id, g.telegram_chat_id """, new { BatchId = batchId, GroupId = groupId }, transaction); } }