diff --git a/Joinrpg.sln b/Joinrpg.sln index 645c3b9f1..62368f7e5 100644 --- a/Joinrpg.sln +++ b/Joinrpg.sln @@ -128,6 +128,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "JoinRpg.Web.Claims", "src\J EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoinRpg.Dal.CommonEfCore", "src\JoinRpg.Dal.CommonEfCore\JoinRpg.Dal.CommonEfCore.csproj", "{A2E3883C-6DFB-4523-9F85-3F0FEC7CD79F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoinRpg.Services.Notifications", "src\JoinRpg.Services.Notifications\JoinRpg.Services.Notifications.csproj", "{14708BF4-BFC4-4059-A15F-F2F3803C8D4E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoinRpg.Services.Notifications.Tests", "src\JoinRpg.Services.Notifications.Tests\JoinRpg.Services.Notifications.Tests.csproj", "{579CCBF4-BD18-468A-ADFC-BED19EB13224}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoinRpg.Dal.Notifications", "src\JoinRpg.Dal.Notifications\JoinRpg.Dal.Notifications.csproj", "{6CD906B0-F1BA-467F-9866-D36FAD7F542B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -330,6 +336,18 @@ Global {A2E3883C-6DFB-4523-9F85-3F0FEC7CD79F}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2E3883C-6DFB-4523-9F85-3F0FEC7CD79F}.Release|Any CPU.ActiveCfg = Release|Any CPU {A2E3883C-6DFB-4523-9F85-3F0FEC7CD79F}.Release|Any CPU.Build.0 = Release|Any CPU + {14708BF4-BFC4-4059-A15F-F2F3803C8D4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14708BF4-BFC4-4059-A15F-F2F3803C8D4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14708BF4-BFC4-4059-A15F-F2F3803C8D4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14708BF4-BFC4-4059-A15F-F2F3803C8D4E}.Release|Any CPU.Build.0 = Release|Any CPU + {579CCBF4-BD18-468A-ADFC-BED19EB13224}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {579CCBF4-BD18-468A-ADFC-BED19EB13224}.Debug|Any CPU.Build.0 = Debug|Any CPU + {579CCBF4-BD18-468A-ADFC-BED19EB13224}.Release|Any CPU.ActiveCfg = Release|Any CPU + {579CCBF4-BD18-468A-ADFC-BED19EB13224}.Release|Any CPU.Build.0 = Release|Any CPU + {6CD906B0-F1BA-467F-9866-D36FAD7F542B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6CD906B0-F1BA-467F-9866-D36FAD7F542B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6CD906B0-F1BA-467F-9866-D36FAD7F542B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6CD906B0-F1BA-467F-9866-D36FAD7F542B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -375,6 +393,9 @@ Global {340E5B6E-2C23-43B6-869D-D9BF807E69D3} = {B8A1A61E-CD98-49F5-98BA-30B0869576B1} {971478A6-DD20-47DA-8B9C-DFC7E19A9240} = {B3BB8906-62C8-44A0-822C-566A144C05AE} {A2E3883C-6DFB-4523-9F85-3F0FEC7CD79F} = {B8A1A61E-CD98-49F5-98BA-30B0869576B1} + {14708BF4-BFC4-4059-A15F-F2F3803C8D4E} = {4D578A76-DAE7-463A-9ABE-268E4D8488DA} + {579CCBF4-BD18-468A-ADFC-BED19EB13224} = {8BB208B6-13CD-4942-B3AC-037BDF858727} + {6CD906B0-F1BA-467F-9866-D36FAD7F542B} = {B8A1A61E-CD98-49F5-98BA-30B0869576B1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {1A5E4F92-A0F4-4350-92C7-361C88D58588} diff --git a/docs/adr003-notifications.md b/docs/adr003-notifications.md new file mode 100644 index 000000000..25b66bd71 --- /dev/null +++ b/docs/adr003-notifications.md @@ -0,0 +1,19 @@ +# Notifications + +Сейчас система выглядит так: +1. Доменный сервис вызывает EmailService, в котором описаны все возможные email, передает ему бизнес-сущности DataModel +1. Тот собирает все нужные данные, подписки и формирует шаблон mailgun, в который в качестве переменных передаются все нужные поля, которые изменяются для каждого получателя, например имя, измененные поля (т.к. не все видят) +1. Дальше через Common.EmailSending → Mailgun_csharp письмо уходит в mailgun, там шаблонизируется, ставится в очередь и отправляется получателям. + +## Проблемы + +1. Шаблон накладывается на стороне mailgun, соответственно нам нужно самостоятельно реализовать шаблонизацию, чтобы отправлять все в телегу +1. Нам нужно отказаться от mailgun (иностранный сервис, а аналог например в Яндексе, не предусматривают шаблонизацию, просто позволяет поставить одно письмо в очередь) + +## Решение +1. Создаем сервис Notifications. Он принимает на вход сообщение-шаблон + id пользователей, кому отослать + поля для них. Шаблонизирует, загружает настройки отправления в , и записывает в БД записи (1 ряд = 1 сообщение 1 пользователю по 1 каналу связи). +1. Пока что реализуем один канал - inbox пользователя, на нем отрабатываем все, а также старую отсылку через mailgun (по прежнему на стороне сервиса) +1. Переключаем все на новый интерфес (Notifications) вместо Common.EmailSending. Убеждаемся, что новая шаблонизация прилично работает. +1. Добавляем джобу, которая реализует отсылку через телегу, добавляем телегу в список каналов, делаем у пользователя настройку через что отправлять (через телегу, через email, оба способа). Привязка аккаунта к телеге и получение разрешения на отправку сообщений от бота уже реализовано +1. Переключаем отправку email вместо шаблонизации на стороне mailgun на нашу шаблонизацию. +1. Меняем отсылку email вместо mailgun на яндексовский сервис без шаблонизации. diff --git a/src/JoinRpg.Dal.Impl/Repositories/UserInfoRepository.cs b/src/JoinRpg.Dal.Impl/Repositories/UserInfoRepository.cs index dd758ac98..d68cfcf9c 100644 --- a/src/JoinRpg.Dal.Impl/Repositories/UserInfoRepository.cs +++ b/src/JoinRpg.Dal.Impl/Repositories/UserInfoRepository.cs @@ -95,4 +95,26 @@ Task IUserRepository.LoadAvatar(AvatarIdentification userAvatarId) return res.Select(a => (new UserIdentification(a.UserId), new AvatarIdentification(a.UserAvatarId))).ToArray(); } + + async Task IUserRepository.GetUsersNotificationInfo(UserIdentification[] userIds) + { + int[] ids = [.. userIds.Select(u => u.Value)]; + var users = await ctx.Set() + .Include(u => u.Auth) + .Include(u => u.ExternalLogins) + .Include(u => u.Extra) + .Where(u => ids.Contains(u.UserId)) + .ToArrayAsync(); + + // TODO Here we need to load only required fields + return [.. + users.Select(u => new UserNotificationInfoDto + { + UserId = new(u.UserId), + DisplayName = u.ExtractDisplayName(), + Email = new Email(u.Email), + TelegramId = u.TryGetTelegramId(), + }) + ]; + } } diff --git a/src/JoinRpg.Dal.Impl/Repositories/UserTransformationExtensions.cs b/src/JoinRpg.Dal.Impl/Repositories/UserTransformationExtensions.cs index ca229c159..9bc76d6ca 100644 --- a/src/JoinRpg.Dal.Impl/Repositories/UserTransformationExtensions.cs +++ b/src/JoinRpg.Dal.Impl/Repositories/UserTransformationExtensions.cs @@ -18,4 +18,19 @@ public static UserDisplayName ExtractDisplayName(this User user) { return UserDisplayName.Create(user.ExtractFullName(), new Email(user.Email)); } + + public static UserExternalLogin? TryGetExternalLoginByProviderId(this User user, string providerId) + { + return user.ExternalLogins.SingleOrDefault(l => l.Provider.Equals(providerId, StringComparison.InvariantCultureIgnoreCase)); + } + + public static TelegramId? TryGetTelegramId(this User user) + { + var elogin = user.TryGetExternalLoginByProviderId("telegram"); + if (elogin == null) + { + return null; + } + return new TelegramId(long.Parse(elogin.Key), PrefferedName.FromOptional(user.Extra?.Telegram)); + } } diff --git a/src/JoinRpg.Dal.Notifications/GlobalUsings.cs b/src/JoinRpg.Dal.Notifications/GlobalUsings.cs new file mode 100644 index 000000000..ec5d0c13d --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/GlobalUsings.cs @@ -0,0 +1,5 @@ +global using System.ComponentModel.DataAnnotations; +global using JoinRpg.Data.Write.Interfaces.Notifications; +global using JoinRpg.PrimitiveTypes.Notifications; +global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Logging; diff --git a/src/JoinRpg.Dal.Notifications/JoinRpg.Dal.Notifications.csproj b/src/JoinRpg.Dal.Notifications/JoinRpg.Dal.Notifications.csproj new file mode 100644 index 000000000..9eac22601 --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/JoinRpg.Dal.Notifications.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/src/JoinRpg.Dal.Notifications/NotificationMessage.cs b/src/JoinRpg.Dal.Notifications/NotificationMessage.cs new file mode 100644 index 000000000..3785ce9aa --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/NotificationMessage.cs @@ -0,0 +1,18 @@ +namespace JoinRpg.Dal.Notifications; + +[Index(nameof(RecepientUserId))] +[Index(nameof(InitiatorUserId))] +internal class NotificationMessage +{ + public int NotificationMessageId { get; set; } + [MaxLength(1024)] + public required string Header { get; set; } + public required string Body { get; set; } + public required int InitiatorUserId { get; set; } + [MaxLength(1024)] + public required string InitiatorAddress { get; set; } + + public required int RecepientUserId { get; set; } + + public virtual HashSet NotificationMessageChannels { get; set; } = []; +} diff --git a/src/JoinRpg.Dal.Notifications/NotificationMessageChannel.cs b/src/JoinRpg.Dal.Notifications/NotificationMessageChannel.cs new file mode 100644 index 000000000..59de835ff --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/NotificationMessageChannel.cs @@ -0,0 +1,16 @@ +namespace JoinRpg.Dal.Notifications; + +[Index(nameof(Channel), nameof(NotificationMessageChannelId), IsUnique = true)] +[Index(nameof(NotificationMessageStatus), nameof(Channel), nameof(NotificationMessageChannelId))] +internal class NotificationMessageChannel +{ + public int NotificationMessageChannelId { get; set; } + public required NotificationMessage NotificationMessage { get; set; } + + public int NotificationMessageId { get; set; } + + public required NotificationChannel Channel { get; set; } + [MaxLength(1024)] + public required string ChannelSpecificValue { get; set; } + public required NotificationMessageStatus NotificationMessageStatus { get; set; } +} diff --git a/src/JoinRpg.Dal.Notifications/NotificationMessageStatus.cs b/src/JoinRpg.Dal.Notifications/NotificationMessageStatus.cs new file mode 100644 index 000000000..1f9c8eaef --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/NotificationMessageStatus.cs @@ -0,0 +1,9 @@ +namespace JoinRpg.Dal.Notifications; + +public enum NotificationMessageStatus +{ + Queued = 0, + Sending = 1, + Sent = 2, + Failed = 3, +} diff --git a/src/JoinRpg.Dal.Notifications/NotificationsDataDbContext.cs b/src/JoinRpg.Dal.Notifications/NotificationsDataDbContext.cs new file mode 100644 index 000000000..db332c1ab --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/NotificationsDataDbContext.cs @@ -0,0 +1,12 @@ +using JoinRpg.Dal.CommonEfCore; + +namespace JoinRpg.Dal.Notifications; + +public class NotificationsDataDbContext(DbContextOptions options) : JoinPostgreSqlEfContextBase(options) +{ + internal DbSet Notifications { get; set; } = null!; + + internal DbSet NotificationMessageChannels { get; set; } = null!; + + +} diff --git a/src/JoinRpg.Dal.Notifications/NotificationsRepository.cs b/src/JoinRpg.Dal.Notifications/NotificationsRepository.cs new file mode 100644 index 000000000..a21da3b4c --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/NotificationsRepository.cs @@ -0,0 +1,127 @@ +using System.Diagnostics.Metrics; + +namespace JoinRpg.Dal.Notifications; + +internal class NotificationsRepository : INotificationRepository +{ + private readonly Counter lostRaceCounter; + private readonly Counter successRaceCounter; + private readonly NotificationsDataDbContext dbContext; + private readonly ILogger logger; + + public NotificationsRepository( + NotificationsDataDbContext dbContext, + ILogger logger, + IMeterFactory meterFactory) + { + this.dbContext = dbContext; + this.logger = logger; + var meter = meterFactory.Create("JoinRpg.Dal.Notifications.Repository"); + lostRaceCounter = meter.CreateCounter("joinRpg.dal.notifications.repository.notifications_select_lost_races"); + successRaceCounter = meter.CreateCounter("joinRpg.dal.notifications.repository.notifications_select_success_races"); + } + + async Task INotificationRepository.InsertNotifications(NotificationMessageDto[] notifications) + { + foreach (var x in notifications) + { + var message = new NotificationMessage() + { + Body = x.Body.Contents!, + Header = x.Header, + InitiatorAddress = x.InitiatorAddress, + InitiatorUserId = x.Initiator.Value, + RecepientUserId = x.Recepient.Value, + NotificationMessageChannels = [ + .. x.Channels.Select(c => new NotificationMessageChannel() + { + Channel = c.Channel, + ChannelSpecificValue = c.ChannelSpecificValue, + NotificationMessageStatus = NotificationMessageStatus.Queued, + NotificationMessage = null!, + }) + ] + }; + _ = dbContext.Notifications.Add(message); + } + _ = await dbContext.SaveChangesAsync(); + + } + async Task INotificationRepository.MarkSendFailed(NotificationId id, NotificationChannel channel) + { + if (!await TrySetStatus(id.Value, channel, from: NotificationMessageStatus.Sending, to: NotificationMessageStatus.Failed)) + { + logger.LogWarning("Notification {notificationId} for channel {channel} failed to set status to failed", id, channel); + } + } + + async Task INotificationRepository.MarkSendSuccess(NotificationId id, NotificationChannel channel) + { + if (!await TrySetStatus(id.Value, channel, from: NotificationMessageStatus.Sending, to: NotificationMessageStatus.Sent)) + { + logger.LogWarning("Notification {notificationId} for channel {channel} failed to set status to success", id, channel); + } + } + async Task<(NotificationId Id, NotificationMessageDto Message)?> INotificationRepository.SelectNextNotificationForSending(NotificationChannel channel) + { + var tryCount = 0; + while (tryCount < 5) + { + var candidate = + await dbContext.NotificationMessageChannels + .Include(c => c.NotificationMessage) + .ThenInclude(m => m.NotificationMessageChannels) + .ForChannelAndStatus(channel, NotificationMessageStatus.Queued) + .FirstOrDefaultAsync(); + + if (candidate is null) + { + return null; + } + + if (await TrySetStatus(candidate.NotificationMessageId, channel, from: NotificationMessageStatus.Queued, to: NotificationMessageStatus.Sending)) + { + successRaceCounter.Add(1); + return (new(candidate.NotificationMessageId), CreateNotificationMessageDto(candidate.NotificationMessage)); + } + + // lost race + + logger.LogDebug("Lost race when try to acquire candidate for sending!"); + lostRaceCounter.Add(1); + await Task.Delay(Random.Shared.Next(100 * tryCount)); + tryCount++; + } + logger.LogWarning("Constantly losing race..."); + return null; + } + + private static NotificationMessageDto CreateNotificationMessageDto(NotificationMessage candidate) + { + + return new NotificationMessageDto + { + Body = new DataModel.MarkdownString(candidate.Body), + Channels = [.. candidate.NotificationMessageChannels.Select(c => new NotificationChannelDto(c.Channel, c.ChannelSpecificValue))], + Header = candidate.Header, + Initiator = new(candidate.InitiatorUserId), + InitiatorAddress = new(candidate.InitiatorAddress), + Recepient = new(candidate.RecepientUserId), + }; + } + + private async Task TrySetStatus(int id, NotificationChannel channel, NotificationMessageStatus from, NotificationMessageStatus to) + { + var totalRows = await dbContext + .NotificationMessageChannels + .Where(n => n.NotificationMessageId == id) + .ForChannelAndStatus(channel, from) + .ExecuteUpdateAsync(ch => ch.SetProperty(x => x.NotificationMessageStatus, to)); + return totalRows switch + { + 0 => false, + 1 => true, + _ => throw new InvalidOperationException("Unexpected — too many rows updated") + }; + } +} diff --git a/src/JoinRpg.Dal.Notifications/Queries.cs b/src/JoinRpg.Dal.Notifications/Queries.cs new file mode 100644 index 000000000..58a815e68 --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/Queries.cs @@ -0,0 +1,13 @@ +namespace JoinRpg.Dal.Notifications; + +internal static class Queries +{ + public static IQueryable ForChannelAndStatus( + this IQueryable query, + NotificationChannel channel, + NotificationMessageStatus status) + => query + .Where(n => n.Channel == channel) + .Where(n => n.NotificationMessageStatus == status); + +} diff --git a/src/JoinRpg.Dal.Notifications/Registrations.cs b/src/JoinRpg.Dal.Notifications/Registrations.cs new file mode 100644 index 000000000..ade5e8d95 --- /dev/null +++ b/src/JoinRpg.Dal.Notifications/Registrations.cs @@ -0,0 +1,14 @@ +using JoinRpg.Dal.CommonEfCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace JoinRpg.Dal.Notifications; +public static class Registrations +{ + public static void AddNotificationsDal(this IServiceCollection services, IConfiguration configuration, IHostEnvironment environment) + { + services.AddJoinEfCoreDbContext(configuration, environment, "Notifications"); + services.AddTransient(); + } +} diff --git a/src/JoinRpg.Data.Interfaces/IUserRepository.cs b/src/JoinRpg.Data.Interfaces/IUserRepository.cs index 5c50f8449..e564bd669 100644 --- a/src/JoinRpg.Data.Interfaces/IUserRepository.cs +++ b/src/JoinRpg.Data.Interfaces/IUserRepository.cs @@ -8,6 +8,8 @@ public interface IUserRepository { Task GetById(int id); + Task GetUsersNotificationInfo(UserIdentification[] userIds); + Task WithProfile(int userId); Task GetWithSubscribe(int currentUserId); Task GetByEmail(string email); diff --git a/src/JoinRpg.Data.Interfaces/UserNotificationInfoDto.cs b/src/JoinRpg.Data.Interfaces/UserNotificationInfoDto.cs new file mode 100644 index 000000000..db900b2af --- /dev/null +++ b/src/JoinRpg.Data.Interfaces/UserNotificationInfoDto.cs @@ -0,0 +1,11 @@ +using JoinRpg.PrimitiveTypes; + +namespace JoinRpg.Data.Interfaces; + +public class UserNotificationInfoDto +{ + public required UserIdentification UserId { get; set; } + public required UserDisplayName DisplayName { get; set; } + public required Email Email { get; set; } + public required TelegramId? TelegramId { get; set; } +} diff --git a/src/JoinRpg.Data.Write.Interfaces/JoinRpg.Data.Write.Interfaces.csproj b/src/JoinRpg.Data.Write.Interfaces/JoinRpg.Data.Write.Interfaces.csproj index 7462c1422..e28d8d844 100644 --- a/src/JoinRpg.Data.Write.Interfaces/JoinRpg.Data.Write.Interfaces.csproj +++ b/src/JoinRpg.Data.Write.Interfaces/JoinRpg.Data.Write.Interfaces.csproj @@ -4,8 +4,6 @@ JoinRpg.Data.Write.Interfaces JoinRpg.Data.Write.Interfaces - - diff --git a/src/JoinRpg.Data.Write.Interfaces/Notifications/INotificationRepository.cs b/src/JoinRpg.Data.Write.Interfaces/Notifications/INotificationRepository.cs new file mode 100644 index 000000000..87ee79732 --- /dev/null +++ b/src/JoinRpg.Data.Write.Interfaces/Notifications/INotificationRepository.cs @@ -0,0 +1,10 @@ +using JoinRpg.PrimitiveTypes.Notifications; + +namespace JoinRpg.Data.Write.Interfaces.Notifications; +public interface INotificationRepository +{ + Task InsertNotifications(NotificationMessageDto[] notifications); + Task<(NotificationId Id, NotificationMessageDto Message)?> SelectNextNotificationForSending(NotificationChannel channel); + Task MarkSendSuccess(NotificationId id, NotificationChannel channel); + Task MarkSendFailed(NotificationId id, NotificationChannel channel); +} diff --git a/src/JoinRpg.Data.Write.Interfaces/Notifications/NotificationChannelDto.cs b/src/JoinRpg.Data.Write.Interfaces/Notifications/NotificationChannelDto.cs new file mode 100644 index 000000000..668ff1d7f --- /dev/null +++ b/src/JoinRpg.Data.Write.Interfaces/Notifications/NotificationChannelDto.cs @@ -0,0 +1,25 @@ +using JoinRpg.PrimitiveTypes; +using JoinRpg.PrimitiveTypes.Notifications; + +namespace JoinRpg.Data.Write.Interfaces.Notifications; + +public record NotificationChannelDto(NotificationChannel Channel, string ChannelSpecificValue) +{ + public Email GetEmail() + { + if (Channel != NotificationChannel.Email) + { + throw new InvalidOperationException(); + } + return new Email(ChannelSpecificValue); + } + + public long GetTelegramId() + { + if (Channel != NotificationChannel.Telegram) + { + throw new InvalidOperationException(); + } + return long.Parse(ChannelSpecificValue); ; + } +} diff --git a/src/JoinRpg.Data.Write.Interfaces/Notifications/NotificationMessageDto.cs b/src/JoinRpg.Data.Write.Interfaces/Notifications/NotificationMessageDto.cs new file mode 100644 index 000000000..7de4a246b --- /dev/null +++ b/src/JoinRpg.Data.Write.Interfaces/Notifications/NotificationMessageDto.cs @@ -0,0 +1,17 @@ +using JoinRpg.DataModel; +using JoinRpg.PrimitiveTypes; + +namespace JoinRpg.Data.Write.Interfaces.Notifications; + +public class NotificationMessageDto +{ + public required MarkdownString Body { get; set; } + public required UserIdentification Initiator { get; set; } + + public required Email InitiatorAddress { get; set; } + public required string Header { get; set; } + + public required UserIdentification Recepient { get; set; } + + public required NotificationChannelDto[] Channels { get; set; } +} diff --git a/src/JoinRpg.Interfaces/Notifications/INotifcationService.cs b/src/JoinRpg.Interfaces/Notifications/INotifcationService.cs new file mode 100644 index 000000000..0c95a9be5 --- /dev/null +++ b/src/JoinRpg.Interfaces/Notifications/INotifcationService.cs @@ -0,0 +1,5 @@ +namespace JoinRpg.Interfaces.Notifications; +public interface INotifcationService +{ + Task QueueNotification(NotificationMessage notificationMessage); +} diff --git a/src/JoinRpg.Interfaces/Notifications/NotificationClass.cs b/src/JoinRpg.Interfaces/Notifications/NotificationClass.cs new file mode 100644 index 000000000..ec87f36ef --- /dev/null +++ b/src/JoinRpg.Interfaces/Notifications/NotificationClass.cs @@ -0,0 +1,11 @@ +namespace JoinRpg.Interfaces.Notifications; + +public enum NotificationClass +{ + Unknown, + UserAccount, + Claims, + Payment, + Forum, + MassProjectEmails, +} diff --git a/src/JoinRpg.Interfaces/Notifications/NotificationMessage.cs b/src/JoinRpg.Interfaces/Notifications/NotificationMessage.cs new file mode 100644 index 000000000..86e6ae777 --- /dev/null +++ b/src/JoinRpg.Interfaces/Notifications/NotificationMessage.cs @@ -0,0 +1,13 @@ +using JoinRpg.DataModel; +using JoinRpg.PrimitiveTypes; + +namespace JoinRpg.Interfaces.Notifications; +public record NotificationMessage( + NotificationClass NotificationClass, + ProjectIdentification? Project, + string Header, + MarkdownString Text, + NotificationRecepient[] Recepients, + UserIdentification Initiator) +{ +} diff --git a/src/JoinRpg.Interfaces/Notifications/NotificationRecepient.cs b/src/JoinRpg.Interfaces/Notifications/NotificationRecepient.cs new file mode 100644 index 000000000..77809a1e6 --- /dev/null +++ b/src/JoinRpg.Interfaces/Notifications/NotificationRecepient.cs @@ -0,0 +1,24 @@ +using JoinRpg.PrimitiveTypes; + +namespace JoinRpg.Interfaces.Notifications; +public record NotificationRecepient(UserIdentification UserId, SubscriptionReason SubscriptionReason, IReadOnlyDictionary? Fields = null) +{ + public IReadOnlyDictionary UserFields { get; set; } = Fields ?? new Dictionary(); + + public static NotificationRecepient MasterOfGame(UserIdentification userId, IReadOnlyDictionary? Fields = null) + => new(userId, SubscriptionReason.MasterOfGame, Fields); + public static NotificationRecepient Player(UserIdentification userId, IReadOnlyDictionary? Fields = null) + => new(userId, SubscriptionReason.Player, Fields); +} + +public enum SubscriptionReason +{ + Unknown = 0, + DirectToYou, + AnswerToYourComment, + Player, + ResponsibleMaster, + Finance, + SubscribedMaster, + MasterOfGame, +} diff --git a/src/JoinRpg.Portal/JoinRpg.Portal.csproj b/src/JoinRpg.Portal/JoinRpg.Portal.csproj index 21a66d55f..9442f488e 100644 --- a/src/JoinRpg.Portal/JoinRpg.Portal.csproj +++ b/src/JoinRpg.Portal/JoinRpg.Portal.csproj @@ -52,6 +52,7 @@ + diff --git a/src/JoinRpg.Portal/Startup.cs b/src/JoinRpg.Portal/Startup.cs index d0e2a6e3f..cdac360d0 100644 --- a/src/JoinRpg.Portal/Startup.cs +++ b/src/JoinRpg.Portal/Startup.cs @@ -2,6 +2,7 @@ using Autofac; using JoinRpg.BlobStorage; using JoinRpg.Common.EmailSending.Impl; +using JoinRpg.Dal.Notifications; using JoinRpg.DI; using JoinRpg.Domain; using JoinRpg.Interfaces; @@ -88,6 +89,7 @@ public void ConfigureServices(IServiceCollection services) services.AddJoinDataProtection(Configuration, environment); services.AddJoinDailyJob(Configuration, environment); + services.AddNotificationsDal(Configuration, environment); if (environment.IsDevelopment()) { diff --git a/src/JoinRpg.PrimitiveTypes/Notifications/NotificationChannel.cs b/src/JoinRpg.PrimitiveTypes/Notifications/NotificationChannel.cs new file mode 100644 index 000000000..c62b16ce2 --- /dev/null +++ b/src/JoinRpg.PrimitiveTypes/Notifications/NotificationChannel.cs @@ -0,0 +1,7 @@ +namespace JoinRpg.PrimitiveTypes.Notifications; + +public enum NotificationChannel +{ + Email, + Telegram, +} diff --git a/src/JoinRpg.PrimitiveTypes/Notifications/NotificationId.cs b/src/JoinRpg.PrimitiveTypes/Notifications/NotificationId.cs new file mode 100644 index 000000000..6e26a6836 --- /dev/null +++ b/src/JoinRpg.PrimitiveTypes/Notifications/NotificationId.cs @@ -0,0 +1,2 @@ +namespace JoinRpg.PrimitiveTypes.Notifications; +public record class NotificationId(int Value) : SingleValueType(Value); diff --git a/src/JoinRpg.Services.Notifications.Tests/GlobalUsings.cs b/src/JoinRpg.Services.Notifications.Tests/GlobalUsings.cs new file mode 100644 index 000000000..47b52d0cf --- /dev/null +++ b/src/JoinRpg.Services.Notifications.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using JoinRpg.DataModel; +global using JoinRpg.PrimitiveTypes; +global using Shouldly; diff --git a/src/JoinRpg.Services.Notifications.Tests/JoinRpg.Services.Notifications.Tests.csproj b/src/JoinRpg.Services.Notifications.Tests/JoinRpg.Services.Notifications.Tests.csproj new file mode 100644 index 000000000..4eb18161c --- /dev/null +++ b/src/JoinRpg.Services.Notifications.Tests/JoinRpg.Services.Notifications.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/src/JoinRpg.Services.Notifications.Tests/TemplaterTests.cs b/src/JoinRpg.Services.Notifications.Tests/TemplaterTests.cs new file mode 100644 index 000000000..c979dadfe --- /dev/null +++ b/src/JoinRpg.Services.Notifications.Tests/TemplaterTests.cs @@ -0,0 +1,35 @@ +namespace JoinRpg.Services.Notifications.Tests; + +public class TemplaterTests +{ + private readonly Dictionary EmptyDict = []; + + [Fact] + public void Helloworld() + { + var templater = new NotifcationFieldsTemplater(new MarkdownString("Hello, %recepient.name%!")); + templater.GetFields().ShouldBe(["name"]); + + templater.Substitute(EmptyDict, new UserDisplayName("Leo", null)).ShouldBe(new MarkdownString("Hello, Leo!")); + } + + [Fact] + public void Twice() + { + var templater = new NotifcationFieldsTemplater(new MarkdownString("Hello, %recepient.name%! Welcome to our %recepient.game%. Your name will be %recepient.name%.")); + templater.GetFields().ShouldBe(["game", "name"]); + + templater.Substitute(new Dictionary { { "game", "Firefly" } }, new UserDisplayName("Leo", null)).ShouldBe(new MarkdownString("Hello, Leo! Welcome to our Firefly. Your name will be Leo.")); + } + + [Fact] + public void CorrectlyReused() + { + var templater = new NotifcationFieldsTemplater(new MarkdownString("Hello, %recepient.name%! Welcome to our %recepient.game%.")); + templater.GetFields().ShouldBe(["game", "name"]); + + var fields = new Dictionary { { "game", "Firefly" } }; + templater.Substitute(fields, new UserDisplayName("Leo", null)).ShouldBe(new MarkdownString("Hello, Leo! Welcome to our Firefly.")); + templater.Substitute(fields, new UserDisplayName("River", null)).ShouldBe(new MarkdownString("Hello, River! Welcome to our Firefly.")); + } +} diff --git a/src/JoinRpg.Services.Notifications/JoinRpg.Services.Notifications.csproj b/src/JoinRpg.Services.Notifications/JoinRpg.Services.Notifications.csproj new file mode 100644 index 000000000..78a69c370 --- /dev/null +++ b/src/JoinRpg.Services.Notifications/JoinRpg.Services.Notifications.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/src/JoinRpg.Services.Notifications/NotifcationFieldsTemplater.cs b/src/JoinRpg.Services.Notifications/NotifcationFieldsTemplater.cs new file mode 100644 index 000000000..f9e589a27 --- /dev/null +++ b/src/JoinRpg.Services.Notifications/NotifcationFieldsTemplater.cs @@ -0,0 +1,46 @@ +using System.Text; +using System.Text.RegularExpressions; +using JoinRpg.DataModel; +using JoinRpg.PrimitiveTypes; + +namespace JoinRpg.Services.Notifications; +/// +/// Used to parse and apply template +/// +/// +/// Class is not thread safe (used StringBuilder) +/// +/// +internal partial class NotifcationFieldsTemplater(MarkdownString Template) +{ + private readonly string template = Template.Contents!; + private readonly MatchCollection matchCollection = FieldPlaceholderRegex().Matches(Template.Contents!); + private readonly StringBuilder stringBuilder = new(Template.Contents!.Length + 50); + + public string[] GetFields() => [.. matchCollection.Select(m => ExtractFieldName(m.Value)).Distinct().Order()]; + + internal MarkdownString Substitute(IReadOnlyDictionary fields, UserDisplayName displayName) + { + var dict = new Dictionary(fields) + { + { "name", displayName.DisplayName } + }; + + var lastChar = 0; + foreach (var match in matchCollection.OrderBy(x => x.Index)) + { + _ = stringBuilder + .Append(template[lastChar..match.Index]) + .Append(dict[ExtractFieldName(match.Value)]); + lastChar = match.Index + match.Length; + } + var result = new MarkdownString(stringBuilder.Append(template[lastChar..]).ToString()); + _ = stringBuilder.Clear(); + return result; + } + + private static string ExtractFieldName(string value) => value["%recepient.".Length..^1]; + + [GeneratedRegex("%recepient\\.(\\w+?)%", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex FieldPlaceholderRegex(); +} diff --git a/src/JoinRpg.Services.Notifications/NotificationServiceImpl.cs b/src/JoinRpg.Services.Notifications/NotificationServiceImpl.cs new file mode 100644 index 000000000..583d69f47 --- /dev/null +++ b/src/JoinRpg.Services.Notifications/NotificationServiceImpl.cs @@ -0,0 +1,94 @@ +using JoinRpg.Data.Interfaces; +using JoinRpg.Data.Write.Interfaces.Notifications; +using JoinRpg.DataModel; +using JoinRpg.Interfaces.Email; +using JoinRpg.Interfaces.Notifications; +using JoinRpg.PrimitiveTypes; +using JoinRpg.PrimitiveTypes.Notifications; + +namespace JoinRpg.Services.Notifications; + +public partial class NotificationServiceImpl( + IUserRepository userRepository, + IEmailSendingService emailSendingService, + INotificationRepository notificationRepository) : INotifcationService +{ + private record class NotificationRow( + UserIdentification UserIdentification, NotificationRecepient Recepient, Email? Email, TelegramId? TelegramId, UserDisplayName DisplayName); + + async Task INotifcationService.QueueNotification(NotificationMessage notificationMessage) + { + var templater = new NotifcationFieldsTemplater(notificationMessage.Text); + + var users = await GetNotificationsForUsers(notificationMessage.Recepients); + var sender = (await userRepository.GetUsersNotificationInfo([notificationMessage.Initiator])).Single(); + + VerifyFieldsPresent(); + + await SendEmailsUsingLegacy(); + + await SaveToQueue(); + + async Task SendEmailsUsingLegacy() + { + await emailSendingService.SendEmails( + notificationMessage.Header, + notificationMessage.Text, + new RecepientData(sender.DisplayName, sender.Email), + users + .Where(u => u.Email is not null) + .Select(u => new RecepientData(u.DisplayName, u.Email!, u.Recepient.Fields)).ToArray()); + } + + async Task SaveToQueue() => await notificationRepository.InsertNotifications( + [.. + users.Select(user => CreateMessageDto(notificationMessage, user, sender, templater.Substitute(user.Recepient.Fields, user.DisplayName)))]); + + void VerifyFieldsPresent() + { + string[] fields = templater.GetFields(); + + foreach (var recepient in notificationMessage.Recepients) + { + if (recepient.Fields.Values.Except(fields).Any()) + { + throw new InvalidOperationException("Not enough fields"); + } + if (fields.Except(recepient.Fields.Values).Any()) + { + throw new InvalidOperationException("Too many fields"); + } + } + } + } + + private NotificationMessageDto CreateMessageDto(NotificationMessage notificationMessage, NotificationRow user, UserNotificationInfoDto sender, MarkdownString body) + { + return new NotificationMessageDto() + { + Body = body, + Header = notificationMessage.Header, + Initiator = notificationMessage.Initiator, + InitiatorAddress = sender.Email, + Recepient = user.UserIdentification, + Channels = GetChannels(user).ToArray(), + }; + + static IEnumerable GetChannels(NotificationRow user) + { + //TODO Add Email channel here + if (user.TelegramId is not null) + { + yield return new(NotificationChannel.Telegram, user.TelegramId.Id.ToString()); + } + } + } + + private async Task GetNotificationsForUsers(NotificationRecepient[] recepients) + { + var recDict = recepients.ToDictionary(r => r.UserId, r => r); + var r = await userRepository.GetUsersNotificationInfo(recepients.Select(r => r.UserId).ToArray()); + + return r.Select(user => new NotificationRow(user.UserId, recDict[user.UserId], user.Email, user.TelegramId, user.DisplayName)).ToArray(); + } +} diff --git a/src/JoinRpg.WebPortal.Managers/MassMailManager.cs b/src/JoinRpg.WebPortal.Managers/MassMailManager.cs index 061f8aac4..8c0179b7e 100644 --- a/src/JoinRpg.WebPortal.Managers/MassMailManager.cs +++ b/src/JoinRpg.WebPortal.Managers/MassMailManager.cs @@ -7,6 +7,7 @@ using JoinRpg.PrimitiveTypes; using JoinRpg.PrimitiveTypes.Access; using JoinRpg.Services.Interfaces.Notification; +using JoinRpg.Web.Models; namespace JoinRpg.WebPortal.Managers; public class MassMailManager( diff --git a/src/JoinRpg.WebPortal.Models/Masters/PermissionExtensions.cs b/src/JoinRpg.WebPortal.Models/Masters/PermissionExtensions.cs index 8ecb8f29b..4f04d2830 100644 --- a/src/JoinRpg.WebPortal.Models/Masters/PermissionExtensions.cs +++ b/src/JoinRpg.WebPortal.Models/Masters/PermissionExtensions.cs @@ -7,6 +7,43 @@ namespace JoinRpg.Web.Models.Masters; public static class PermissionExtensions { + [Pure] + public static Func GetPermssionExpression(this Permission permission) + { + return permission switch + { + Permission.None => acl => true, + Permission.CanChangeFields => acl => acl.CanChangeFields, + Permission.CanChangeProjectProperties => acl => acl.CanChangeProjectProperties, + Permission.CanGrantRights => acl => acl.CanGrantRights, + Permission.CanManageClaims => acl => acl.CanManageClaims, + Permission.CanEditRoles => acl => acl.CanEditRoles, + Permission.CanManageMoney => acl => acl.CanManageMoney, + Permission.CanSendMassMails => acl => acl.CanSendMassMails, + Permission.CanManagePlots => acl => acl.CanManagePlots, + Permission.CanManageAccommodation => acl => acl.CanManageAccommodation, + Permission.CanSetPlayersAccommodations => acl => acl.CanSetPlayersAccommodations, + _ => throw new ArgumentOutOfRangeException(nameof(permission)), + }; + } + + [Pure] + public static Permission[] GetPermissions(this ProjectAcl acl) + { + return Impl().ToArray(); + + IEnumerable Impl() + { + foreach (var permission in Enum.GetValues()) + { + if (permission.GetPermssionExpression()(acl)) + { + yield return permission; + } + } + } + } + [Pure] public static PermissionBadgeViewModel[] GetPermissionViewModels(this ProjectAcl acl) { diff --git a/src/Joinrpg.Dal.Migrate/Joinrpg.Dal.Migrate.csproj b/src/Joinrpg.Dal.Migrate/Joinrpg.Dal.Migrate.csproj index a4240b58e..4bfd226e9 100644 --- a/src/Joinrpg.Dal.Migrate/Joinrpg.Dal.Migrate.csproj +++ b/src/Joinrpg.Dal.Migrate/Joinrpg.Dal.Migrate.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Joinrpg.Dal.Migrate/Program.cs b/src/Joinrpg.Dal.Migrate/Program.cs index 34f44971e..0232622d0 100644 --- a/src/Joinrpg.Dal.Migrate/Program.cs +++ b/src/Joinrpg.Dal.Migrate/Program.cs @@ -1,6 +1,7 @@ using Joinrpg.Dal.Migrate.Ef6; using Joinrpg.Dal.Migrate.EfCore; using JoinRpg.Dal.JobService; +using JoinRpg.Dal.Notifications; using JoinRpg.Portal.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -26,5 +27,6 @@ public static IHostBuilder CreateHostBuilder(string[] args) => services.RegisterMigrator(hostContext.Configuration.GetConnectionString("DataProtection")!); services.RegisterMigrator(hostContext.Configuration.GetConnectionString("DailyJob")!); + services.RegisterMigrator(hostContext.Configuration.GetConnectionString("Notifications")!); }); }