using System.Text; using Dapper; using GmRelay.Shared.Domain; using Npgsql; namespace GmRelay.Web.Services; public sealed class CalendarSubscriptionService(NpgsqlDataSource dataSource) { private const string IcsProdId = "-//GM-Relay//TTRPG Schedule//EN"; public string GenerateToken() => Guid.NewGuid().ToString("N"); public async Task CreateSubscriptionAsync( long userTelegramId, Guid? groupId, CalendarSubscriptionFilter filter, CancellationToken ct = default) { var token = GenerateToken(); await using var connection = await dataSource.OpenConnectionAsync(ct); await connection.ExecuteAsync( @"INSERT INTO calendar_subscriptions (id, token, user_telegram_id, group_id, filter_type, created_at, expires_at) VALUES (gen_random_uuid(), @token, @userTelegramId, @groupId, @filterType, now(), NULL)", new { token, userTelegramId, groupId, filterType = (int)filter }); return token; } public async Task GetIcsAsync(string token, CancellationToken ct = default) { await using var connection = await dataSource.OpenConnectionAsync(ct); var subscription = await connection.QueryFirstOrDefaultAsync( @"SELECT id, user_telegram_id as UserTelegramId, group_id as GroupId, filter_type as FilterType FROM calendar_subscriptions WHERE token = @token AND (expires_at IS NULL OR expires_at > now())", new { token }); if (subscription is null) throw new SubscriptionNotFoundException(); var sessions = await connection.QueryAsync( subscription.FilterType == (int)CalendarSubscriptionFilter.SpecificGroup && subscription.GroupId.HasValue ? @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt FROM sessions s WHERE s.group_id = @GroupId AND s.status = @Planned AND s.scheduled_at > NOW() ORDER BY s.scheduled_at ASC" : @"SELECT s.id as Id, s.title as Title, s.scheduled_at as ScheduledAt FROM sessions s WHERE s.status = @Planned AND s.scheduled_at > NOW() ORDER BY s.scheduled_at ASC", new { subscription.GroupId, Planned = SessionStatus.Planned }); var sb = new StringBuilder(); sb.AppendLine("BEGIN:VCALENDAR"); sb.AppendLine("VERSION:2.0"); sb.AppendLine($"PRODID:{IcsProdId}"); sb.AppendLine("CALSCALE:GREGORIAN"); sb.AppendLine("METHOD:PUBLISH"); foreach (var s in sessions) { var dtStart = FormatIcsDate(s.ScheduledAt); var dtEnd = FormatIcsDate(s.ScheduledAt.AddHours(4)); sb.AppendLine("BEGIN:VEVENT"); sb.AppendLine($"UID:{s.Id}@gmrelay"); sb.AppendLine($"DTSTAMP:{FormatIcsDate(DateTime.UtcNow)}"); sb.AppendLine($"DTSTART:{dtStart}"); sb.AppendLine($"DTEND:{dtEnd}"); sb.AppendLine($"SUMMARY:{EscapeIcsText(s.Title)}"); sb.AppendLine("END:VEVENT"); } sb.AppendLine("END:VCALENDAR"); return sb.ToString(); } private static string FormatIcsDate(DateTime dt) => dt.ToUniversalTime().ToString("yyyyMMddTHHmmssZ"); private static string EscapeIcsText(string text) => text .Replace("\\", "\\\\") .Replace(";", "\\;") .Replace(",", "\\,") .Replace("\n", "\\n") .Replace("\r", ""); private sealed record SubscriptionRecord(Guid Id, long UserTelegramId, Guid? GroupId, int FilterType); private sealed record CalendarSessionDto(Guid Id, string Title, DateTime ScheduledAt); }