From b205967f1a0db577dee2ba1ba24b8ea8da39e878 Mon Sep 17 00:00:00 2001 From: Toutsu Date: Thu, 7 May 2026 10:15:06 +0300 Subject: [PATCH] feat(#13): add CalendarSubscriptionService with token generation and ICS rendering --- .../Services/CalendarSubscriptionService.cs | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/GmRelay.Web/Services/CalendarSubscriptionService.cs diff --git a/src/GmRelay.Web/Services/CalendarSubscriptionService.cs b/src/GmRelay.Web/Services/CalendarSubscriptionService.cs new file mode 100644 index 0000000..a3f72af --- /dev/null +++ b/src/GmRelay.Web/Services/CalendarSubscriptionService.cs @@ -0,0 +1,93 @@ +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); +}