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, string ManagerRole = GroupManagerRoleExtensions.OwnerValue); public sealed record WebGroupManager( long TelegramId, string DisplayName, string? TelegramUsername, string Role, DateTime AddedAt); public sealed record WebGroupManagement( WebGameGroup Group, IReadOnlyList Managers, bool CurrentUserIsOwner); 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, string NotificationMode = SessionNotificationModeExtensions.GroupAndDirectValue); internal sealed record WebPromotedParticipantDto(Guid ParticipantRowId, string DisplayName); internal sealed record WebDirectNotificationRecipient(long TelegramId, string DisplayName); internal sealed record WebBatchInfo( Guid BatchId, Guid GroupId, string Title, string JoinLink, long TelegramChatId, int? BatchMessageId, int? ThreadId, string NotificationMode); internal sealed record WebBatchSessionRow( Guid Id, Guid GroupId, string Title, string JoinLink, DateTime ScheduledAt, string Status, int? MaxPlayers, int? BatchMessageId, long TelegramChatId, int? ThreadId, string NotificationMode); internal sealed record WebTemplateGroupDto(long TelegramChatId); 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 g.id, g.telegram_chat_id AS TelegramChatId, g.name, g.gm_telegram_id AS GmTelegramId, gm.role AS ManagerRole FROM group_managers gm JOIN players p ON p.id = gm.player_id JOIN game_groups g ON g.id = gm.group_id WHERE p.telegram_id = @GmId ORDER BY g.name """, new { GmId = gmId })).ToList(); } public async Task GetGroupAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( """ SELECT g.id, g.telegram_chat_id AS TelegramChatId, g.name, g.gm_telegram_id AS GmTelegramId, @OwnerRole AS ManagerRole FROM game_groups g WHERE g.id = @GroupId """, new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task IsGroupManagerAsync(Guid groupId, long telegramId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId AND p.telegram_id = @TelegramId ) """, new { GroupId = groupId, TelegramId = telegramId }); } public async Task IsGroupOwnerAsync(Guid groupId, long telegramId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.ExecuteScalarAsync( """ SELECT EXISTS ( SELECT 1 FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId AND p.telegram_id = @TelegramId AND gm.role = @OwnerRole ) """, new { GroupId = groupId, TelegramId = telegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue }); } public async Task> GetGroupManagersAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT p.telegram_id AS TelegramId, p.display_name AS DisplayName, p.telegram_username AS TelegramUsername, gm.role AS Role, gm.created_at AS AddedAt FROM group_managers gm JOIN players p ON p.id = gm.player_id WHERE gm.group_id = @GroupId ORDER BY CASE gm.role WHEN @OwnerRole THEN 0 ELSE 1 END, gm.created_at, p.display_name """, new { GroupId = groupId, OwnerRole = GroupManagerRoleExtensions.OwnerValue })).ToList(); } public async Task AddGroupCoGmAsync( Guid groupId, long ownerTelegramId, long coGmTelegramId, string displayName, string? telegramUsername) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); await conn.ExecuteAsync( """ INSERT INTO players (telegram_id, display_name, telegram_username) VALUES (@TelegramId, @DisplayName, @TelegramUsername) ON CONFLICT (telegram_id) DO UPDATE SET display_name = EXCLUDED.display_name, telegram_username = EXCLUDED.telegram_username """, new { TelegramId = coGmTelegramId, DisplayName = displayName, TelegramUsername = telegramUsername }, transaction); await conn.ExecuteAsync( """ INSERT INTO group_managers (group_id, player_id, role, added_by_player_id) SELECT @GroupId, co_gm.id, @CoGmRole, owner_player.id FROM players co_gm LEFT JOIN players owner_player ON owner_player.telegram_id = @OwnerTelegramId WHERE co_gm.telegram_id = @CoGmTelegramId ON CONFLICT (group_id, player_id) DO UPDATE SET role = CASE WHEN group_managers.role = @OwnerRole THEN group_managers.role ELSE EXCLUDED.role END, added_by_player_id = EXCLUDED.added_by_player_id """, new { GroupId = groupId, OwnerTelegramId = ownerTelegramId, CoGmTelegramId = coGmTelegramId, OwnerRole = GroupManagerRoleExtensions.OwnerValue, CoGmRole = GroupManagerRoleExtensions.CoGmValue }, transaction); await transaction.CommitAsync(); } public async Task RemoveGroupCoGmAsync(Guid groupId, long coGmTelegramId) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( """ DELETE FROM group_managers gm USING players p WHERE gm.player_id = p.id AND gm.group_id = @GroupId AND p.telegram_id = @CoGmTelegramId AND gm.role = @CoGmRole """, new { GroupId = groupId, CoGmTelegramId = coGmTelegramId, CoGmRole = GroupManagerRoleExtensions.CoGmValue }); } 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, s.notification_mode AS NotificationMode 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, s.notification_mode AS NotificationMode 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, (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode 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, s.notification_mode AS NotificationMode 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, one_hour_reminder_processed_at = CASE WHEN scheduled_at <> @ScheduledAt THEN NULL ELSE one_hour_reminder_processed_at END, 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); var mode = SessionNotificationModeExtensions.FromDatabaseValue(oldSession.NotificationMode); if (mode.ShouldSendDirectMessages()) { var recipients = await LoadSessionDirectRecipientsAsync(conn, sessionId); await SendDirectNotificationsAsync(recipients, notification, "web-session-updated", sessionId); } 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, s.notification_mode AS NotificationMode 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 UpdateBatchNotificationModeAsync(Guid batchId, Guid groupId, SessionNotificationMode notificationMode) { 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 notification_mode = @NotificationMode, updated_at = now() WHERE batch_id = @BatchId AND group_id = @GroupId """, new { BatchId = batchId, GroupId = groupId, NotificationMode = notificationMode.ToDatabaseValue() }, transaction); if (updatedRows == 0) { throw new SessionAccessDeniedException(batchId, 0); } await transaction.CommitAsync(); } 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, s.notification_mode AS NotificationMode 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, one_hour_reminder_processed_at = NULL, 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); var mode = SessionNotificationModeExtensions.FromDatabaseValue(firstSession.NotificationMode); if (mode.ShouldSendDirectMessages()) { var recipients = await LoadBatchDirectRecipientsAsync(conn, batchId); await SendDirectNotificationsAsync(recipients, notification, "web-batch-rescheduled", batchId); } } 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, s.notification_mode AS NotificationMode 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, notification_mode) VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @ThreadId, @MaxPlayers, @NotificationMode) RETURNING id """, new { BatchId = newBatchId, sourceSession.GroupId, Title = batchTitle, JoinLink = batchJoinLink, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, ThreadId = threadId, sourceSession.MaxPlayers, sourceSession.NotificationMode }, 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, sourceSessions[0].NotificationMode); } public async Task> GetCampaignTemplatesAsync(Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); return (await conn.QueryAsync( """ SELECT id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt FROM campaign_templates WHERE group_id = @GroupId ORDER BY created_at DESC, name """, new { GroupId = groupId })).ToList(); } public async Task GetCampaignTemplateAsync(Guid templateId) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleOrDefaultAsync( """ SELECT id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt FROM campaign_templates WHERE id = @TemplateId """, new { TemplateId = templateId }); } public async Task CreateCampaignTemplateAsync(Guid groupId, CreateCampaignTemplateRequest request) { await using var conn = await dataSource.OpenConnectionAsync(); return await conn.QuerySingleAsync( """ INSERT INTO campaign_templates ( group_id, name, title, join_link, session_count, interval_days, max_players, notification_mode ) VALUES ( @GroupId, @Name, @Title, @JoinLink, @SessionCount, @IntervalDays, @MaxPlayers, @NotificationMode ) ON CONFLICT (group_id, name) DO UPDATE SET title = EXCLUDED.title, join_link = EXCLUDED.join_link, session_count = EXCLUDED.session_count, interval_days = EXCLUDED.interval_days, max_players = EXCLUDED.max_players, notification_mode = EXCLUDED.notification_mode, updated_at = now() RETURNING id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt """, new { GroupId = groupId, request.Name, request.Title, request.JoinLink, request.SessionCount, request.IntervalDays, request.MaxPlayers, NotificationMode = request.NotificationMode.ToDatabaseValue() }); } public async Task DeleteCampaignTemplateAsync(Guid templateId, Guid groupId) { await using var conn = await dataSource.OpenConnectionAsync(); await conn.ExecuteAsync( "DELETE FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId", new { TemplateId = templateId, GroupId = groupId }); } public async Task CreateBatchFromTemplateAsync(Guid templateId, Guid groupId, DateTime firstScheduledAt) { await using var conn = await dataSource.OpenConnectionAsync(); await using var transaction = await conn.BeginTransactionAsync(); var template = await conn.QuerySingleOrDefaultAsync( """ SELECT id AS Id, group_id AS GroupId, name AS Name, title AS Title, join_link AS JoinLink, session_count AS SessionCount, interval_days AS IntervalDays, max_players AS MaxPlayers, notification_mode AS NotificationMode, created_at AS CreatedAt, updated_at AS UpdatedAt FROM campaign_templates WHERE id = @TemplateId AND group_id = @GroupId FOR UPDATE """, new { TemplateId = templateId, GroupId = groupId }, transaction); if (template is null) { throw new SessionAccessDeniedException(templateId, 0); } var group = await conn.QuerySingleOrDefaultAsync( "SELECT telegram_chat_id AS TelegramChatId FROM game_groups WHERE id = @GroupId", new { GroupId = groupId }, transaction); if (group is null) { throw new SessionAccessDeniedException(groupId, 0); } var schedule = BatchSchedulePlanner.BuildRecurringSchedule( firstScheduledAt, template.SessionCount, template.IntervalDays); var batchId = Guid.NewGuid(); var renderedSessions = new List(); foreach (var scheduledAt in schedule) { var sessionId = await conn.ExecuteScalarAsync( """ INSERT INTO sessions (batch_id, group_id, title, join_link, scheduled_at, status, max_players, notification_mode) VALUES (@BatchId, @GroupId, @Title, @JoinLink, @ScheduledAt, @Status, @MaxPlayers, @NotificationMode) RETURNING id """, new { BatchId = batchId, GroupId = groupId, template.Title, template.JoinLink, ScheduledAt = scheduledAt, Status = SessionStatus.Planned, template.MaxPlayers, template.NotificationMode }, transaction); renderedSessions.Add(new SessionBatchDto(sessionId, scheduledAt, SessionStatus.Planned, template.MaxPlayers)); } await transaction.CommitAsync(); var renderResult = SessionBatchRenderer.Render(template.Title, renderedSessions, Array.Empty()); var batchMessage = await bot.SendMessage( chatId: group.TelegramChatId, 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 = batchId }); return new WebSessionBatch( batchId, groupId, template.Title, template.JoinLink, renderedSessions.Min(session => session.ScheduledAt), renderedSessions.Max(session => session.ScheduledAt), renderedSessions.Count, template.NotificationMode); } private async Task> LoadSessionDirectRecipientsAsync( Npgsql.NpgsqlConnection conn, Guid sessionId) { return (await conn.QueryAsync( """ SELECT p.telegram_id AS TelegramId, 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 = @Active """, new { SessionId = sessionId, Active = ParticipantRegistrationStatus.Active })).ToList(); } private async Task> LoadBatchDirectRecipientsAsync( Npgsql.NpgsqlConnection conn, Guid batchId) { return (await conn.QueryAsync( """ SELECT DISTINCT p.telegram_id AS TelegramId, p.display_name AS DisplayName FROM session_participants sp JOIN players p ON p.id = sp.player_id JOIN sessions s ON s.id = sp.session_id WHERE s.batch_id = @BatchId AND sp.is_gm = false AND sp.registration_status = @Active """, new { BatchId = batchId, Active = ParticipantRegistrationStatus.Active })).ToList(); } private async Task SendDirectNotificationsAsync( IEnumerable recipients, string htmlText, string notificationKind, Guid entityId) { foreach (var recipient in recipients) { try { await bot.SendMessage( chatId: recipient.TelegramId, text: htmlText, parseMode: Telegram.Bot.Types.Enums.ParseMode.Html); } catch (Exception ex) { logger.LogWarning( ex, "Failed to send {NotificationKind} DM for entity {EntityId} to player {TelegramId} ({DisplayName})", notificationKind, entityId, recipient.TelegramId, recipient.DisplayName); } } } 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, (array_agg(s.notification_mode ORDER BY s.scheduled_at))[1] AS NotificationMode 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); } }