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( @"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( @"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(); await messenger.SendCalendarFileAsync( new PlatformCalendarFile( command.Group, "schedule.ics", bytes, "📅 Ваш календарь игр!\nОткройте файл на устройстве, чтобы добавить события в свой календарь.", actions), cancellationToken); } }