From aba41738275a57f8593a64a0ad9220a3a88aac54 Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sat, 31 Oct 2026 09:38:05 +0200 Subject: [PATCH] feat(templates): add localized push templates and actions Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery. Ref: IT-1627 --- .../Messages/PushActionMetadata.cs | 6 + .../Messages/PushTemplateData.cs | 7 + .../Messages/SendPushMessageData.cs | 7 +- .../IPushTemplateDefinitionRepository.cs | 11 + .../Templates/PushTemplateActionValidation.cs | 49 ++ .../Templates/PushTemplateActions.cs | 10 + .../Templates/PushTemplateButton.cs | 10 + .../Templates/PushTemplateDefinition.cs | 11 + .../Templates/PushTemplateTranslation.cs | 9 + .../Templates/PushTemplateValidation.cs | 42 ++ ...ilTemplateDefinitionEntityConfiguration.cs | 3 + ...shTemplateDefinitionEntityConfiguration.cs | 31 + .../PushTemplateEntityConfiguration.cs | 30 + .../TemplateEntityConfiguration.cs | 21 + .../Entities/EmailTemplateDefinitionEntity.cs | 1 + .../Entities/PushTemplateDefinitionEntity.cs | 13 + .../Entities/PushTemplateEntity.cs | 15 + .../Entities/PushTemplateVariableData.cs | 7 + .../Entities/TemplateEntity.cs | 12 + ...1030215421_AddChannelTemplates.Designer.cs | 674 +++++++++++++++++ .../20261030215421_AddChannelTemplates.cs | 151 ++++ ...0235620_AddPushTemplateActions.Designer.cs | 679 ++++++++++++++++++ .../20261030235620_AddPushTemplateActions.cs | 29 + .../NotificationDbContextModelSnapshot.cs | 215 ++++++ .../NotificationDbContext.cs | 3 + .../Repositories/EmailTemplateRepository.cs | 21 + .../NotificationServiceRepository.cs | 18 +- .../PushTemplateDefinitionRepository.cs | 108 +++ .../ServiceCollectionExtensions.cs | 1 + .../Push/FcmPushSenderTests.cs | 24 +- .../Push/PushChannelTestServiceTests.cs | 6 +- .../Push/PushDeliveryTests.cs | 2 +- .../Push/PushPayloadVersionTests.cs | 105 +++ .../Push/PushTemplateRendererTests.cs | 90 +++ .../Push/SendPushDeliveryValidatorTests.cs | 2 +- .../AdminDeletionTests.cs | 4 +- .../AdminTemplatesIndexViewTests.cs | 2 +- .../ChannelTemplateModelTests.cs | 153 ++++ .../EditorLayoutTests.cs | 6 +- .../PushChannelFormatTests.cs | 55 ++ .../PushTemplateEditorTests.cs | 173 +++++ .../RecordingFirebasePushClient.cs | 22 + .../TemplateCatalogPresentationTests.cs | 2 +- .../Admin/AdminPushTemplatesController.cs | 162 +++++ .../Admin/AdminTemplatesController.cs | 9 +- .../ViewModels/EmailTemplateEditViewModel.cs | 3 +- .../ViewModels/PushTemplateEditViewModel.cs | 22 + .../PushTemplateLanguageEditViewModel.cs | 38 + .../ViewModels/ServiceTemplatesViewModel.cs | 1 + .../TemplateChannelNavigationViewModel.cs | 10 + .../Views/AdminPushChannels/Edit.cshtml | 32 +- .../Views/AdminPushTemplates/Delete.cshtml | 12 + .../Views/AdminPushTemplates/Edit.cshtml | 111 +++ .../Views/AdminPushTemplates/Missing.cshtml | 23 + .../AdminTemplates/DeleteDefinition.cshtml | 8 +- .../Views/AdminTemplates/Edit.cshtml | 6 +- .../Views/AdminTemplates/Index.cshtml | 41 +- .../Views/Shared/_Layout.cshtml | 2 +- .../Shared/_TemplateChannelNavigation.cshtml | 7 + .../wwwroot/css/admin.css | 8 + .../wwwroot/js/email-template-editor.js | 79 +- .../wwwroot/js/template-channel-navigation.js | 10 + .../Push/FcmPushSender.cs | 22 +- .../Push/FirebasePushClient.cs | 89 ++- .../Push/IFirebasePushClient.cs | 4 +- .../Push/PushChannelTestMessage.cs | 31 + .../Push/PushChannelTestService.cs | 16 +- .../Push/PushChannelTestSettings.cs | 4 + .../Push/PushDeliveryService.cs | 2 +- .../Push/PushMessageValidator.cs | 22 +- .../Push/PushTemplateRenderer.cs | 56 ++ .../Push/RenderedPushAction.cs | 3 + .../Push/RenderedPushMessageData.cs | 7 + .../SendPushConsumer.cs | 5 + README.md | 2 + docs/android-push.md | 25 + docs/channel-template-model.md | 266 +++++++ docs/email-template-localizations.md | 9 + docs/supported-message-types.md | 23 + 79 files changed, 3906 insertions(+), 104 deletions(-) create mode 100644 HrynCo.NotificationService.Contracts/Messages/PushActionMetadata.cs create mode 100644 HrynCo.NotificationService.Contracts/Messages/PushTemplateData.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActionValidation.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActions.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateButton.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateDefinition.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateTranslation.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateValidation.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateDefinitionEntityConfiguration.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateEntityConfiguration.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Configurations/TemplateEntityConfiguration.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/PushTemplateDefinitionEntity.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/PushTemplateEntity.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/PushTemplateVariableData.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/TemplateEntity.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.Designer.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.Designer.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Repositories/PushTemplateDefinitionRepository.cs create mode 100644 HrynCo.NotificationService.Services.Tests/Push/PushPayloadVersionTests.cs create mode 100644 HrynCo.NotificationService.Services.Tests/Push/PushTemplateRendererTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/ChannelTemplateModelTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/PushChannelFormatTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/PushTemplateEditorTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/RecordingFirebasePushClient.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/AdminPushTemplatesController.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateEditViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateLanguageEditViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/TemplateChannelNavigationViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Views/AdminPushTemplates/Delete.cshtml create mode 100644 HrynCo.NotificationService.Web/Views/AdminPushTemplates/Edit.cshtml create mode 100644 HrynCo.NotificationService.Web/Views/AdminPushTemplates/Missing.cshtml create mode 100644 HrynCo.NotificationService.Web/Views/Shared/_TemplateChannelNavigation.cshtml create mode 100644 HrynCo.NotificationService.Web/wwwroot/js/template-channel-navigation.js create mode 100644 HrynCo.NotificationService.Worker.Services/Push/PushChannelTestMessage.cs create mode 100644 HrynCo.NotificationService.Worker.Services/Push/PushTemplateRenderer.cs create mode 100644 HrynCo.NotificationService.Worker.Services/Push/RenderedPushAction.cs create mode 100644 HrynCo.NotificationService.Worker.Services/Push/RenderedPushMessageData.cs create mode 100644 docs/channel-template-model.md diff --git a/HrynCo.NotificationService.Contracts/Messages/PushActionMetadata.cs b/HrynCo.NotificationService.Contracts/Messages/PushActionMetadata.cs new file mode 100644 index 0000000..907eaa1 --- /dev/null +++ b/HrynCo.NotificationService.Contracts/Messages/PushActionMetadata.cs @@ -0,0 +1,6 @@ +namespace HrynCo.NotificationService.Contracts.Messages; + +public sealed record PushActionMetadata +{ + public string? ObjectId { get; init; } +} diff --git a/HrynCo.NotificationService.Contracts/Messages/PushTemplateData.cs b/HrynCo.NotificationService.Contracts/Messages/PushTemplateData.cs new file mode 100644 index 0000000..e844c8a --- /dev/null +++ b/HrynCo.NotificationService.Contracts/Messages/PushTemplateData.cs @@ -0,0 +1,7 @@ +namespace HrynCo.NotificationService.Contracts.Messages; + +public sealed record PushTemplateData +{ + public required IReadOnlyDictionary Bindings { get; init; } + public required IReadOnlyDictionary Actions { get; init; } +} diff --git a/HrynCo.NotificationService.Contracts/Messages/SendPushMessageData.cs b/HrynCo.NotificationService.Contracts/Messages/SendPushMessageData.cs index c84e70c..5f23cde 100644 --- a/HrynCo.NotificationService.Contracts/Messages/SendPushMessageData.cs +++ b/HrynCo.NotificationService.Contracts/Messages/SendPushMessageData.cs @@ -6,9 +6,12 @@ public sealed record SendPushMessageData public required Guid BindingId { get; init; } public required Guid UserId { get; init; } public required string ServiceName { get; init; } - public required string Title { get; init; } - public required string Body { get; init; } + public string? Title { get; init; } + public string? Body { get; init; } public string Route { get; init; } = "/home"; + public string? TemplateKey { get; init; } + public string? LanguageCode { get; init; } + public PushTemplateData? TemplateData { get; init; } public int Version { get; init; } = 1; public required DateTimeOffset ExpiresAt { get; init; } public string? Token { get; init; } diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs new file mode 100644 index 0000000..6819287 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IPushTemplateDefinitionRepository.cs @@ -0,0 +1,11 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.DAL.Abstract.Repositories; + +public interface IPushTemplateDefinitionRepository +{ + Task> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default); + Task GetAsync(Guid serviceId, string key, CancellationToken ct = default); + Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default); + Task DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default); +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActionValidation.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActionValidation.cs new file mode 100644 index 0000000..0aa05ba --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActionValidation.cs @@ -0,0 +1,49 @@ +using System.Text.Json; + +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public static class PushTemplateActionValidation +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = false + }; + + public static PushTemplateActions Parse(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new EmailTemplateWriteException("Actions JSON is required. Use {} for no actions.", false); + try + { + var actions = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new JsonException(); + Validate(actions); + return actions; + } + catch (JsonException) + { + throw new EmailTemplateWriteException("Invalid actions JSON. Use defaultAction and buttons with action and label.", false); + } + } + + public static void Validate(PushTemplateActions actions) + { + if (actions.DefaultAction is not null) ValidateKey(actions.DefaultAction); + if (actions.Buttons is null || actions.Buttons.Count > 3) + throw new EmailTemplateWriteException("Configure up to three push buttons.", false); + var keys = new HashSet(StringComparer.Ordinal); + foreach (var button in actions.Buttons) + { + if (button is null) throw new EmailTemplateWriteException("A button needs an action and label.", false); + ValidateKey(button.Action); + if (!keys.Add(button.Action) || string.IsNullOrWhiteSpace(button.Label) || button.Label.Length > 100) + throw new EmailTemplateWriteException("Button actions must be unique and labels must contain 1–100 characters.", false); + } + } + + private static void ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || key.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '_' and not '-' and not '.')) + throw new EmailTemplateWriteException("Action keys need 1–100 letters, digits, dots, underscores or hyphens.", false); + } +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActions.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActions.cs new file mode 100644 index 0000000..741dd0b --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateActions.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record PushTemplateActions +{ + public string? DefaultAction { get; init; } + public IReadOnlyList Buttons { get; init; } = []; +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateButton.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateButton.cs new file mode 100644 index 0000000..c12547c --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateButton.cs @@ -0,0 +1,10 @@ +using System.Text.Json.Serialization; + +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)] +public sealed record PushTemplateButton +{ + public required string Action { get; init; } + public required string Label { get; init; } +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateDefinition.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateDefinition.cs new file mode 100644 index 0000000..326c65a --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateDefinition.cs @@ -0,0 +1,11 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class PushTemplateDefinition +{ + public Guid Id { get; set; } + public Guid ServiceId { get; set; } + public required string Key { get; set; } + public Guid Revision { get; set; } + public IReadOnlyList Variables { get; set; } = []; + public IReadOnlyList Languages { get; set; } = []; +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateTranslation.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateTranslation.cs new file mode 100644 index 0000000..39a027e --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateTranslation.cs @@ -0,0 +1,9 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class PushTemplateTranslation +{ + public string ActionsJson { get; set; } = "{}"; + public required string LanguageCode { get; set; } + public required string Title { get; set; } + public required string Body { get; set; } +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateValidation.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateValidation.cs new file mode 100644 index 0000000..ea2ba96 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/PushTemplateValidation.cs @@ -0,0 +1,42 @@ +using System.Text.RegularExpressions; + +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public static partial class PushTemplateValidation +{ + public static void Validate(PushTemplateDefinition definition) + { + EmailTemplateValidation.ValidateIdentity("Push", definition.Key); + EmailTemplateValidation.ValidateVariables(definition.Variables); + var names = definition.Variables.Select(v => v.Name).ToHashSet(StringComparer.Ordinal); + foreach (var language in definition.Languages) + ValidateTranslation(language, names); + if (definition.Languages.Select(l => l.LanguageCode).Distinct(StringComparer.Ordinal).Count() != definition.Languages.Count) + throw new EmailTemplateWriteException("Language codes must be unique within a template.", false); + } + + private static void ValidateTranslation(PushTemplateTranslation translation, HashSet names) + { + if (string.IsNullOrWhiteSpace(translation.LanguageCode) || translation.LanguageCode.Length > 10) + throw new EmailTemplateWriteException("A valid language code is required.", false); + if (string.IsNullOrWhiteSpace(translation.Title) || string.IsNullOrWhiteSpace(translation.Body)) + throw new EmailTemplateWriteException("Every populated push language needs both Title and Body.", false); + ValidatePlaceholders(translation.Title, names); + ValidatePlaceholders(translation.Body, names); + var actions = PushTemplateActionValidation.Parse(translation.ActionsJson); + foreach (var button in actions.Buttons) ValidatePlaceholders(button.Label, names); + } + + private static void ValidatePlaceholders(string text, HashSet names) + { + foreach (Match match in Placeholder().Matches(text)) + if (!names.Contains(match.Groups[1].Value)) + throw new EmailTemplateWriteException("Every placeholder must be declared in this channel's variables.", false); + var remainder = Placeholder().Replace(text, ""); + if (remainder.Contains("{{", StringComparison.Ordinal) || remainder.Contains("}}", StringComparison.Ordinal)) + throw new EmailTemplateWriteException("Use complete placeholders in the form {{VariableName}}.", false); + } + + [GeneratedRegex(@"\{\{([^{}]+)\}\}")] + private static partial Regex Placeholder(); +} diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs index fd5853c..01da119 100644 --- a/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs +++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs @@ -17,6 +17,9 @@ internal sealed class EmailTemplateDefinitionEntityConfiguration : IEntityTypeCo builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique(); builder.HasAlternateKey(x => new { x.Id, x.ServiceId }); builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict); + builder.HasOne(x => x.Template).WithOne(x => x.Email) + .HasForeignKey(x => new { x.ServiceId, x.Key }) + .HasPrincipalKey(x => new { x.ServiceId, x.Key }).OnDelete(DeleteBehavior.Cascade); builder.Property(x => x.Created).HasColumnName("created"); builder.Property(x => x.Updated).HasColumnName("updated"); builder.OwnsMany(x => x.Variables, v => diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateDefinitionEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateDefinitionEntityConfiguration.cs new file mode 100644 index 0000000..33a1a63 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateDefinitionEntityConfiguration.cs @@ -0,0 +1,31 @@ +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace HrynCo.NotificationService.DAL.EF.Configurations; + +internal sealed class PushTemplateDefinitionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("push_template_definitions"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.ServiceId).HasColumnName("service_id"); + builder.Property(x => x.Key).HasColumnName("key").HasMaxLength(100).IsRequired(); + builder.Property(x => x.Revision).HasColumnName("revision").IsConcurrencyToken(); + builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique(); + builder.HasAlternateKey(x => new { x.Id, x.ServiceId }); + builder.HasOne(x => x.Template).WithOne(x => x.Push) + .HasForeignKey(x => new { x.ServiceId, x.Key }) + .HasPrincipalKey(x => new { x.ServiceId, x.Key }).OnDelete(DeleteBehavior.Cascade); + builder.Property(x => x.Created).HasColumnName("created"); + builder.Property(x => x.Updated).HasColumnName("updated"); + builder.OwnsMany(x => x.Variables, v => + { + v.ToJson("variables"); + v.Property(x => x.Name).HasJsonPropertyName("name"); + v.Property(x => x.Required).HasJsonPropertyName("required"); + }); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateEntityConfiguration.cs new file mode 100644 index 0000000..55bd633 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/PushTemplateEntityConfiguration.cs @@ -0,0 +1,30 @@ +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace HrynCo.NotificationService.DAL.EF.Configurations; + +internal sealed class PushTemplateEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("push_templates"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.DefinitionId).HasColumnName("definition_id"); + builder.Property(x => x.ServiceId).HasColumnName("service_id"); + builder.Property(x => x.ServiceLanguageId).HasColumnName("service_language_id"); + builder.HasIndex(x => new { x.DefinitionId, x.ServiceLanguageId }).IsUnique(); + builder.HasOne(x => x.Definition).WithMany(x => x.Languages) + .HasForeignKey(x => new { x.DefinitionId, x.ServiceId }) + .HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Cascade); + builder.HasOne(x => x.ServiceLanguage).WithMany() + .HasForeignKey(x => new { x.ServiceLanguageId, x.ServiceId }) + .HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Restrict); + builder.Property(x => x.Title).HasColumnName("title").IsRequired(); + builder.Property(x => x.Body).HasColumnName("body").IsRequired(); + builder.Property(x => x.ActionsJson).HasColumnName("actions").HasColumnType("jsonb").IsRequired(); + builder.Property(x => x.Created).HasColumnName("created"); + builder.Property(x => x.Updated).HasColumnName("updated"); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/TemplateEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/TemplateEntityConfiguration.cs new file mode 100644 index 0000000..090381c --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/TemplateEntityConfiguration.cs @@ -0,0 +1,21 @@ +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace HrynCo.NotificationService.DAL.EF.Configurations; + +internal sealed class TemplateEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("templates"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.ServiceId).HasColumnName("service_id"); + builder.Property(x => x.Key).HasColumnName("key").HasMaxLength(100).IsRequired(); + builder.HasAlternateKey(x => new { x.ServiceId, x.Key }); + builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict); + builder.Property(x => x.Created).HasColumnName("created"); + builder.Property(x => x.Updated).HasColumnName("updated"); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateDefinitionEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateDefinitionEntity.cs index 9dcac36..9172ee8 100644 --- a/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateDefinitionEntity.cs +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateDefinitionEntity.cs @@ -4,6 +4,7 @@ namespace HrynCo.NotificationService.DAL.EF.Entities; internal sealed class EmailTemplateDefinitionEntity : Entity { + public TemplateEntity Template { get; set; } = null!; public Guid ServiceId { get; set; } public ServiceEntity Service { get; set; } = null!; public required string Key { get; set; } diff --git a/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateDefinitionEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateDefinitionEntity.cs new file mode 100644 index 0000000..4550428 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateDefinitionEntity.cs @@ -0,0 +1,13 @@ +using HrynCo.DAL.Abstract.Entities; + +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class PushTemplateDefinitionEntity : Entity +{ + public Guid ServiceId { get; set; } + public required string Key { get; set; } + public TemplateEntity Template { get; set; } = null!; + public Guid Revision { get; set; } = Guid.NewGuid(); + public List Variables { get; set; } = []; + public List Languages { get; set; } = []; +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateEntity.cs new file mode 100644 index 0000000..eaf9916 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateEntity.cs @@ -0,0 +1,15 @@ +using HrynCo.DAL.Abstract.Entities; + +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class PushTemplateEntity : Entity +{ + public Guid DefinitionId { get; set; } + public PushTemplateDefinitionEntity Definition { get; set; } = null!; + public Guid ServiceId { get; set; } + public Guid ServiceLanguageId { get; set; } + public ServiceLanguageEntity ServiceLanguage { get; set; } = null!; + public required string Title { get; set; } + public required string Body { get; set; } + public string ActionsJson { get; set; } = "{}"; +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateVariableData.cs b/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateVariableData.cs new file mode 100644 index 0000000..859a91c --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/PushTemplateVariableData.cs @@ -0,0 +1,7 @@ +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class PushTemplateVariableData +{ + public required string Name { get; set; } + public bool Required { get; set; } +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/TemplateEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/TemplateEntity.cs new file mode 100644 index 0000000..c5de039 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/TemplateEntity.cs @@ -0,0 +1,12 @@ +using HrynCo.DAL.Abstract.Entities; + +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class TemplateEntity : Entity +{ + public Guid ServiceId { get; set; } + public ServiceEntity Service { get; set; } = null!; + public required string Key { get; set; } + public EmailTemplateDefinitionEntity? Email { get; set; } + public PushTemplateDefinitionEntity? Push { get; set; } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.Designer.cs new file mode 100644 index 0000000..b4c93e5 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.Designer.cs @@ -0,0 +1,674 @@ +// +using System; +using HrynCo.NotificationService.DAL.EF; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + [DbContext(typeof(NotificationDbContext))] + [Migration("20261030215421_AddChannelTemplates")] + partial class AddChannelTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DailyLimit") + .HasColumnType("integer") + .HasColumnName("daily_limit"); + + b.Property("EmailChannelType") + .HasColumnType("integer") + .HasColumnName("provider_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MonthlyLimit") + .HasColumnType("integer") + .HasColumnName("monthly_limit"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("WarnThresholdPercent") + .HasColumnType("integer") + .HasColumnName("warn_threshold_percent"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Priority"); + + b.ToTable("email_channels", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("SentCount") + .HasColumnType("integer") + .HasColumnName("sent_count"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "Date") + .IsUnique(); + + b.ToTable("email_channel_usage", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Key") + .IsUnique(); + + b.ToTable("email_template_definitions", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId", "ServiceId"); + + b.HasIndex("DefinitionId", "ServiceLanguageId") + .IsUnique(); + + b.HasIndex("ServiceLanguageId", "ServiceId"); + + b.ToTable("email_templates", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CredentialsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("credentials"); + + b.Property("FirebaseProjectId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("firebase_project_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId") + .IsUnique(); + + b.ToTable("push_channels", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b => + { + b.Property("ServiceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvalidRegistration") + .HasColumnType("boolean"); + + b.HasKey("ServiceName", "MessageId"); + + b.HasIndex("CompletedAt"); + + b.ToTable("PushDeliveries", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Key") + .IsUnique(); + + b.ToTable("push_template_definitions", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId", "ServiceId"); + + b.HasIndex("DefinitionId", "ServiceLanguageId") + .IsUnique(); + + b.HasIndex("ServiceLanguageId", "ServiceId"); + + b.ToTable("push_templates", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("services", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("code"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Code") + .IsUnique(); + + b.ToTable("service_languages", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.ToTable("templates", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null) + .WithMany("UsageRecords") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template") + .WithOne("Email") + .HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key") + .HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 => + { + b1.Property("EmailTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("Required") + .HasJsonPropertyName("required"); + + b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal"); + + b1.ToTable("email_template_definitions"); + + b1 + .ToJson("variables") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("EmailTemplateDefinitionEntityId"); + }); + + b.Navigation("Service"); + + b.Navigation("Template"); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition") + .WithMany("Languages") + .HasForeignKey("DefinitionId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage") + .WithMany() + .HasForeignKey("ServiceLanguageId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("ServiceLanguage"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template") + .WithOne("Push") + .HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key") + .HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 => + { + b1.Property("PushTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("Required") + .HasJsonPropertyName("required"); + + b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal"); + + b1.ToTable("push_template_definitions"); + + b1 + .ToJson("variables") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("PushTemplateDefinitionEntityId"); + }); + + b.Navigation("Template"); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition") + .WithMany("Languages") + .HasForeignKey("DefinitionId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage") + .WithMany() + .HasForeignKey("ServiceLanguageId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("ServiceLanguage"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany("Languages") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.Navigation("UsageRecords"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.Navigation("Email"); + + b.Navigation("Push"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.cs new file mode 100644 index 0000000..7f01b55 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261030215421_AddChannelTemplates.cs @@ -0,0 +1,151 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + /// + public partial class AddChannelTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "templates", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + service_id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false), + updated = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_templates", x => x.id); + table.UniqueConstraint("AK_templates_service_id_key", x => new { x.service_id, x.key }); + table.ForeignKey( + name: "FK_templates_services_service_id", + column: x => x.service_id, + principalTable: "services", + principalColumn: "id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "push_template_definitions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + service_id = table.Column(type: "uuid", nullable: false), + key = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + revision = table.Column(type: "uuid", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false), + updated = table.Column(type: "timestamp with time zone", nullable: true), + variables = table.Column(type: "jsonb", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_push_template_definitions", x => x.id); + table.UniqueConstraint("AK_push_template_definitions_id_service_id", x => new { x.id, x.service_id }); + table.ForeignKey( + name: "FK_push_template_definitions_templates_service_id_key", + columns: x => new { x.service_id, x.key }, + principalTable: "templates", + principalColumns: new[] { "service_id", "key" }, + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "push_templates", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + definition_id = table.Column(type: "uuid", nullable: false), + service_id = table.Column(type: "uuid", nullable: false), + service_language_id = table.Column(type: "uuid", nullable: false), + title = table.Column(type: "text", nullable: false), + body = table.Column(type: "text", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false), + updated = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_push_templates", x => x.id); + table.ForeignKey( + name: "FK_push_templates_push_template_definitions_definition_id_serv~", + columns: x => new { x.definition_id, x.service_id }, + principalTable: "push_template_definitions", + principalColumns: new[] { "id", "service_id" }, + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_push_templates_service_languages_service_language_id_servic~", + columns: x => new { x.service_language_id, x.service_id }, + principalTable: "service_languages", + principalColumns: new[] { "id", "service_id" }, + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_push_template_definitions_service_id_key", + table: "push_template_definitions", + columns: new[] { "service_id", "key" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_push_templates_definition_id_service_id", + table: "push_templates", + columns: new[] { "definition_id", "service_id" }); + + migrationBuilder.CreateIndex( + name: "IX_push_templates_definition_id_service_language_id", + table: "push_templates", + columns: new[] { "definition_id", "service_language_id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_push_templates_service_language_id_service_id", + table: "push_templates", + columns: new[] { "service_language_id", "service_id" }); + + // Preserve existing email identity, variables, revisions and translations in place. + migrationBuilder.Sql(""" + INSERT INTO templates (id, service_id, key, created, updated) + SELECT id, service_id, key, created, updated FROM email_template_definitions; + """); + + migrationBuilder.AddForeignKey( + name: "FK_email_template_definitions_templates_service_id_key", + table: "email_template_definitions", + columns: new[] { "service_id", "key" }, + principalTable: "templates", + principalColumns: new[] { "service_id", "key" }, + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM push_template_definitions) THEN + RAISE EXCEPTION 'Cannot roll back channel templates while push definitions exist. Export and explicitly remove them first.'; + END IF; + END $$; + """); + migrationBuilder.DropForeignKey( + name: "FK_email_template_definitions_templates_service_id_key", + table: "email_template_definitions"); + + migrationBuilder.DropTable( + name: "push_templates"); + + migrationBuilder.DropTable( + name: "push_template_definitions"); + + migrationBuilder.DropTable( + name: "templates"); + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.Designer.cs new file mode 100644 index 0000000..e69bf6f --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.Designer.cs @@ -0,0 +1,679 @@ +// +using System; +using HrynCo.NotificationService.DAL.EF; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + [DbContext(typeof(NotificationDbContext))] + [Migration("20261030235620_AddPushTemplateActions")] + partial class AddPushTemplateActions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DailyLimit") + .HasColumnType("integer") + .HasColumnName("daily_limit"); + + b.Property("EmailChannelType") + .HasColumnType("integer") + .HasColumnName("provider_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MonthlyLimit") + .HasColumnType("integer") + .HasColumnName("monthly_limit"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("WarnThresholdPercent") + .HasColumnType("integer") + .HasColumnName("warn_threshold_percent"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Priority"); + + b.ToTable("email_channels", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("SentCount") + .HasColumnType("integer") + .HasColumnName("sent_count"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ProviderId", "Date") + .IsUnique(); + + b.ToTable("email_channel_usage", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Key") + .IsUnique(); + + b.ToTable("email_template_definitions", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId", "ServiceId"); + + b.HasIndex("DefinitionId", "ServiceLanguageId") + .IsUnique(); + + b.HasIndex("ServiceLanguageId", "ServiceId"); + + b.ToTable("email_templates", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CredentialsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("credentials"); + + b.Property("FirebaseProjectId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("firebase_project_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId") + .IsUnique(); + + b.ToTable("push_channels", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b => + { + b.Property("ServiceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvalidRegistration") + .HasColumnType("boolean"); + + b.HasKey("ServiceName", "MessageId"); + + b.HasIndex("CompletedAt"); + + b.ToTable("PushDeliveries", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Key") + .IsUnique(); + + b.ToTable("push_template_definitions", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActionsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("actions"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId", "ServiceId"); + + b.HasIndex("DefinitionId", "ServiceLanguageId") + .IsUnique(); + + b.HasIndex("ServiceLanguageId", "ServiceId"); + + b.ToTable("push_templates", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("services", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("code"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Code") + .IsUnique(); + + b.ToTable("service_languages", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.ToTable("templates", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null) + .WithMany("UsageRecords") + .HasForeignKey("ProviderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template") + .WithOne("Email") + .HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key") + .HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 => + { + b1.Property("EmailTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("Required") + .HasJsonPropertyName("required"); + + b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal"); + + b1.ToTable("email_template_definitions"); + + b1 + .ToJson("variables") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("EmailTemplateDefinitionEntityId"); + }); + + b.Navigation("Service"); + + b.Navigation("Template"); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition") + .WithMany("Languages") + .HasForeignKey("DefinitionId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage") + .WithMany() + .HasForeignKey("ServiceLanguageId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("ServiceLanguage"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template") + .WithOne("Push") + .HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key") + .HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 => + { + b1.Property("PushTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("Required") + .HasJsonPropertyName("required"); + + b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal"); + + b1.ToTable("push_template_definitions"); + + b1 + .ToJson("variables") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("PushTemplateDefinitionEntityId"); + }); + + b.Navigation("Template"); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition") + .WithMany("Languages") + .HasForeignKey("DefinitionId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage") + .WithMany() + .HasForeignKey("ServiceLanguageId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("ServiceLanguage"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany("Languages") + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => + { + b.Navigation("UsageRecords"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.Navigation("Email"); + + b.Navigation("Push"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.cs new file mode 100644 index 0000000..44ac1bb --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261030235620_AddPushTemplateActions.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + /// + public partial class AddPushTemplateActions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "actions", + table: "push_templates", + type: "jsonb", + nullable: false, + defaultValue: "{}"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "actions", + table: "push_templates"); + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs index b028b4c..4bd6107 100644 --- a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs +++ b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs @@ -272,6 +272,98 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.ToTable("PushDeliveries", (string)null); }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("ServiceId", "Key") + .IsUnique(); + + b.ToTable("push_template_definitions", (string)null); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActionsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("actions"); + + b.Property("Body") + .IsRequired() + .HasColumnType("text") + .HasColumnName("body"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId", "ServiceId"); + + b.HasIndex("DefinitionId", "ServiceLanguageId") + .IsUnique(); + + b.HasIndex("ServiceLanguageId", "ServiceId"); + + b.ToTable("push_templates", (string)null); + }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => { b.Property("Id") @@ -339,6 +431,36 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.ToTable("service_languages", (string)null); }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.HasKey("Id"); + + b.ToTable("templates", (string)null); + }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => { b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") @@ -367,6 +489,13 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations .OnDelete(DeleteBehavior.Restrict) .IsRequired(); + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template") + .WithOne("Email") + .HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key") + .HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 => { b1.Property("EmailTemplateDefinitionEntityId"); @@ -395,6 +524,8 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.Navigation("Service"); + b.Navigation("Template"); + b.Navigation("Variables"); }); @@ -430,6 +561,67 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.Navigation("Service"); }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template") + .WithOne("Push") + .HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key") + .HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 => + { + b1.Property("PushTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("Required") + .HasJsonPropertyName("required"); + + b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal"); + + b1.ToTable("push_template_definitions"); + + b1 + .ToJson("variables") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("PushTemplateDefinitionEntityId"); + }); + + b.Navigation("Template"); + + b.Navigation("Variables"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition") + .WithMany("Languages") + .HasForeignKey("DefinitionId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage") + .WithMany() + .HasForeignKey("ServiceLanguageId", "ServiceId") + .HasPrincipalKey("Id", "ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Definition"); + + b.Navigation("ServiceLanguage"); + }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b => { b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") @@ -441,6 +633,17 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.Navigation("Service"); }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") + .WithMany() + .HasForeignKey("ServiceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Service"); + }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => { b.Navigation("UsageRecords"); @@ -451,10 +654,22 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.Navigation("Languages"); }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b => + { + b.Navigation("Languages"); + }); + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => { b.Navigation("Languages"); }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b => + { + b.Navigation("Email"); + + b.Navigation("Push"); + }); #pragma warning restore 612, 618 } } diff --git a/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs b/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs index f04bd28..db8094a 100644 --- a/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs +++ b/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs @@ -10,6 +10,9 @@ public class NotificationDbContext : DbContext { } + internal DbSet TemplateCatalog => Set(); + internal DbSet PushTemplateDefinitions => Set(); + internal DbSet PushTemplates => Set(); internal DbSet ServiceLanguages => Set(); internal DbSet PushChannels => Set(); internal DbSet Services => Set(); diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index 388c271..bd37be1 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -78,6 +78,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template if (entity is not null) throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating."); entity = new EmailTemplateDefinitionEntity { Service = service, ServiceId = service.Id, Key = patch.Key, Created = DateTimeOffset.UtcNow }; + entity.Template = await GetOrCreateTemplateAsync(service, patch.Key, ct); db.TemplateDefinitions.Add(entity); return entity; } @@ -125,6 +126,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template if (definition.Revision != revision) throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting."); db.TemplateDefinitions.Remove(definition); + await RemoveEmptyTemplateAsync(serviceId, key, ct); removed = true; }, ct); return removed; @@ -261,12 +263,31 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template ?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false); var entity = new EmailTemplateDefinitionEntity { + Template = await GetOrCreateTemplateAsync(service, key, ct), Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow }; db.TemplateDefinitions.Add(entity); return entity; } + private async Task GetOrCreateTemplateAsync(ServiceEntity service, string key, CancellationToken ct) + { + var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == key, ct); + if (template is not null) + return template; + template = new TemplateEntity { Service = service, ServiceId = service.Id, Key = key, Created = DateTimeOffset.UtcNow }; + db.TemplateCatalog.Add(template); + return template; + } + + private async Task RemoveEmptyTemplateAsync(Guid serviceId, string key, CancellationToken ct) + { + if (await db.PushTemplateDefinitions.AnyAsync(t => t.ServiceId == serviceId && t.Key == key, ct)) + return; + var template = await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct); + db.TemplateCatalog.Remove(template); + } + private static void ValidateLegacy(EmailTemplate template) { EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key); diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs index 7779974..9056b25 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs @@ -48,7 +48,7 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te private async Task EnsureServiceIsEmptyAsync(Guid id, CancellationToken ct) { - if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct)) + if (await db.TemplateCatalog.AnyAsync(d => d.ServiceId == id, ct)) throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations."); if (await db.Providers.AnyAsync(c => c.ServiceId == id, ct) || await db.PushChannels.AnyAsync(c => c.ServiceId == id, ct)) throw new EmailTemplateWriteException("Reassign or remove every notification channel before deleting this service."); @@ -76,9 +76,11 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te var language = service.Languages.SingleOrDefault(l => l.Id == languageId) ?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service."); var versions = await db.Templates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct); - if (service.Revision != expectedRevision || versions.Count != expectedVersionCount) + var pushVersions = await db.PushTemplates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct); + if (service.Revision != expectedRevision || versions.Count + pushVersions.Count != expectedVersionCount) throw new EmailTemplateWriteException("The service or affected version count changed. Review the current count and confirm again."); db.Templates.RemoveRange(versions); + db.PushTemplates.RemoveRange(pushVersions); // Save dependent removals first because language deletion is restricted by its FK. await db.SaveChangesAsync(ct); db.ServiceLanguages.Remove(language); @@ -112,7 +114,8 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te var removed = service.Languages.Where(l => !retained.Contains(l.Id)).ToList(); foreach (var language in removed) { - var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct); + var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct) + + await db.PushTemplates.CountAsync(t => t.ServiceLanguageId == language.Id, ct); if (count > 0) throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation."); } @@ -168,10 +171,17 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te definition.Revision = Guid.NewGuid(); definition.Updated = DateTimeOffset.UtcNow; } + var pushDefinitions = await db.PushTemplateDefinitions.Where(d => d.ServiceId == serviceId).ToListAsync(ct); + foreach (var definition in pushDefinitions) + { + definition.Revision = Guid.NewGuid(); + definition.Updated = DateTimeOffset.UtcNow; + } } private Task> VersionCountsAsync(CancellationToken ct) => db.Templates.AsNoTracking() - .GroupBy(t => t.ServiceLanguageId).Select(g => new { Id = g.Key, Count = g.Count() }) + .Select(t => t.ServiceLanguageId).Concat(db.PushTemplates.Select(t => t.ServiceLanguageId)) + .GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.Id, g => g.Count, ct); private static void Validate(NotificationServiceInfo service) diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/PushTemplateDefinitionRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/PushTemplateDefinitionRepository.cs new file mode 100644 index 0000000..258fbd2 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/PushTemplateDefinitionRepository.cs @@ -0,0 +1,108 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; + +namespace HrynCo.NotificationService.DAL.EF.Repositories; + +internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, TemplateMutationRunner mutations) + : IPushTemplateDefinitionRepository +{ + private IQueryable Definitions => db.PushTemplateDefinitions + .Include(d => d.Languages).ThenInclude(l => l.ServiceLanguage); + + public async Task> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) => + (await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct)).Select(Map).ToList(); + + public async Task GetAsync(Guid serviceId, string key, CancellationToken ct = default) + { + var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct); + return entity is null ? null : Map(entity); + } + + public Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () => + { + PushTemplateValidation.Validate(definition); + var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == definition.ServiceId, ct) + ?? throw new EmailTemplateWriteException("Service no longer exists. Reload services."); + if (definition.Languages.Any(l => !service.Languages.Any(s => s.Code == l.LanguageCode))) + throw new EmailTemplateWriteException("Every language must exactly match this service's catalog.", false); + var entity = await LoadForSaveAsync(definition, service, isNew, ct); + entity.Variables = definition.Variables.Select(v => new PushTemplateVariableData { Name = v.Name, Required = v.Required }).ToList(); + SynchronizeLanguages(entity, service, definition.Languages); + entity.Revision = Guid.NewGuid(); + entity.Updated = DateTimeOffset.UtcNow; + definition.Id = entity.Id; + definition.Revision = entity.Revision; + }, ct); + + private async Task LoadForSaveAsync(PushTemplateDefinition model, ServiceEntity service, bool isNew, CancellationToken ct) + { + var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == service.Id && d.Key == model.Key, ct); + if (!isNew) + { + if (entity is null || entity.Id != model.Id || entity.Revision != model.Revision) + throw new EmailTemplateWriteException("This push template changed after you opened it. Reload before saving; your entered values are retained."); + return entity; + } + if (entity is not null) + throw new EmailTemplateWriteException("A push template with this key already exists."); + var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == model.Key, ct) + ?? new TemplateEntity { Service = service, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow }; + entity = new PushTemplateDefinitionEntity { Template = template, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow }; + db.PushTemplateDefinitions.Add(entity); + return entity; + } + + private void SynchronizeLanguages(PushTemplateDefinitionEntity definition, ServiceEntity service, IReadOnlyList translations) + { + var codes = translations.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal); + db.PushTemplates.RemoveRange(definition.Languages.Where(l => !codes.Contains(l.ServiceLanguage.Code))); + foreach (var translation in translations) + { + var entity = definition.Languages.SingleOrDefault(l => l.ServiceLanguage.Code == translation.LanguageCode); + if (entity is null) + { + entity = new PushTemplateEntity + { + Definition = definition, ServiceId = service.Id, + ServiceLanguage = service.Languages.Single(l => l.Code == translation.LanguageCode), + Title = translation.Title, Body = translation.Body, Created = DateTimeOffset.UtcNow + }; + definition.Languages.Add(entity); + } + entity.Title = translation.Title; + entity.Body = translation.Body; + entity.ActionsJson = translation.ActionsJson; + entity.Updated = DateTimeOffset.UtcNow; + } + } + + public async Task DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) + { + var removed = false; + await mutations.ExecuteAsync(async () => + { + var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct); + if (entity is null) + return; + if (revision == Guid.Empty || entity.Revision != revision) + throw new EmailTemplateWriteException("The push template revision changed. Reload before deleting."); + db.PushTemplateDefinitions.Remove(entity); + if (!await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == serviceId && d.Key == key, ct)) + db.TemplateCatalog.Remove(await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct)); + removed = true; + }, ct); + return removed; + } + + private static PushTemplateDefinition Map(PushTemplateDefinitionEntity entity) => new() + { + Id = entity.Id, ServiceId = entity.ServiceId, Key = entity.Key, Revision = entity.Revision, + Variables = entity.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), + Languages = entity.Languages.OrderBy(l => l.ServiceLanguage.Code).Select(l => new PushTemplateTranslation + { + LanguageCode = l.ServiceLanguage.Code, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson + }).ToList() + }; +} diff --git a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs index f08183d..1ee4b5d 100644 --- a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs +++ b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs @@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/HrynCo.NotificationService.Services.Tests/Push/FcmPushSenderTests.cs b/HrynCo.NotificationService.Services.Tests/Push/FcmPushSenderTests.cs index 14d0704..527138d 100644 --- a/HrynCo.NotificationService.Services.Tests/Push/FcmPushSenderTests.cs +++ b/HrynCo.NotificationService.Services.Tests/Push/FcmPushSenderTests.cs @@ -12,12 +12,16 @@ namespace HrynCo.NotificationService.Services.Tests.Push; public sealed class FcmPushSenderTests { - [Fact] - public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials() + [Theory] + [InlineData(1)] + [InlineData(2)] + public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials(int version) { var services = Substitute.For(); var channels = Substitute.For(); var client = Substitute.For(); + var templates = Substitute.For(); + templates.GetAsync(Arg.Any(), "Test", Arg.Any()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] }); var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory-Renamed" }; var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true }; services.GetByNameAsync(service.Name, Arg.Any()).Returns(service); @@ -29,12 +33,16 @@ public sealed class FcmPushSenderTests private_key = rsa.ExportPkcs8PrivateKeyPem(), token_uri = "https://untrusted.invalid/token" }); channels.GetCredentialsAsync(channel.Id, Arg.Any()).Returns(json); - client.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + client.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new PushDeliveryOutcome(null)); - var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None); + var data = version == 1 ? Data(service.Name) with { Version = 1, Title = "Legacy title", Body = "Legacy body", TemplateKey = null, TemplateData = null } : Data(service.Name); + var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), data, CancellationToken.None); Assert.Null(result.Error); - await client.Received(1).SendAsync(Arg.Any(), Arg.Any(), + await client.Received(1).SendAsync(Arg.Any(), Arg.Any(), Arg.Is(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any()); + await templates.Received(version == 1 ? 0 : 1).GetAsync(Arg.Any(), Arg.Any(), Arg.Any()); + if (version == 1) + await client.Received().SendAsync(Arg.Any(), Arg.Is(m => m.Title == "Legacy title" && m.Body == "Legacy body" && m.Buttons.Count == 0), Arg.Any(), Arg.Any()); } [Theory] @@ -46,6 +54,8 @@ public sealed class FcmPushSenderTests var services = Substitute.For(); var channels = Substitute.For(); var client = Substitute.For(); + var templates = Substitute.For(); + templates.GetAsync(Arg.Any(), "Test", Arg.Any()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] }); var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" }; if (exists) services.GetByNameAsync(service.Name, Arg.Any()).Returns(service); if (active) @@ -54,7 +64,7 @@ public sealed class FcmPushSenderTests channels.GetActiveForServiceAsync(service.Id, Arg.Any()).Returns(channel); channels.GetCredentialsAsync(channel.Id, Arg.Any()).Returns("secret-must-not-leak"); } - var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None); + var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None); Assert.NotNull(result.Error); Assert.DoesNotContain("secret-must-not-leak", result.Error); await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default); @@ -72,7 +82,7 @@ public sealed class FcmPushSenderTests private static SendPushMessageData Data(string serviceName) => new() { RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), - ServiceName = serviceName, Title = "Test", Body = "Test", Token = "test-device-token", + ServiceName = serviceName, Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary(), Actions = new Dictionary() }, Token = "test-device-token", TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10) }; } diff --git a/HrynCo.NotificationService.Services.Tests/Push/PushChannelTestServiceTests.cs b/HrynCo.NotificationService.Services.Tests/Push/PushChannelTestServiceTests.cs index 5e944da..74c55a7 100644 --- a/HrynCo.NotificationService.Services.Tests/Push/PushChannelTestServiceTests.cs +++ b/HrynCo.NotificationService.Services.Tests/Push/PushChannelTestServiceTests.cs @@ -23,7 +23,7 @@ public sealed class PushChannelTestServiceTests var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings { ChannelId = id, FirebaseProjectId = "test-project", RecipientToken = send ? "test-token" : null }, send, default); Assert.Null(outcome.Error); - await firebase.Received(send ? 1 : 0).SendTestAsync("test-token", Arg.Any(), Arg.Any()); + await firebase.Received(send ? 1 : 0).SendTestAsync(Arg.Is(m => m.Token == "test-token" && m.Version == 1), Arg.Any(), Arg.Any()); await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(Arg.Any(), Arg.Any()); await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default); } @@ -70,7 +70,7 @@ public sealed class PushChannelTestServiceTests { var channels = Substitute.For(); var firebase = Client(); - firebase.SendTestAsync(Arg.Any(), Arg.Any(), Arg.Any()) + firebase.SendTestAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new PushRetryException(TimeSpan.FromSeconds(30)))); var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings { FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default); @@ -82,7 +82,7 @@ public sealed class PushChannelTestServiceTests var firebase = Substitute.For(); firebase.CheckAuthorizationAsync(Arg.Any(), Arg.Any()) .Returns(new PushDeliveryOutcome(null)); - firebase.SendTestAsync(Arg.Any(), Arg.Any(), Arg.Any()) + firebase.SendTestAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new PushDeliveryOutcome(null)); return firebase; } diff --git a/HrynCo.NotificationService.Services.Tests/Push/PushDeliveryTests.cs b/HrynCo.NotificationService.Services.Tests/Push/PushDeliveryTests.cs index 6bf993a..9837dbf 100644 --- a/HrynCo.NotificationService.Services.Tests/Push/PushDeliveryTests.cs +++ b/HrynCo.NotificationService.Services.Tests/Push/PushDeliveryTests.cs @@ -49,7 +49,7 @@ public sealed class PushDeliveryTests Data = new SendPushMessageData { RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), - ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Title = "Invemory", Body = "Test", TokenHash = new string('a', 64) + ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary(), Actions = new Dictionary() }, TokenHash = new string('a', 64) } }; await service.CompleteFailureAsync(id, message, default); diff --git a/HrynCo.NotificationService.Services.Tests/Push/PushPayloadVersionTests.cs b/HrynCo.NotificationService.Services.Tests/Push/PushPayloadVersionTests.cs new file mode 100644 index 0000000..05054e1 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/Push/PushPayloadVersionTests.cs @@ -0,0 +1,105 @@ +using System.Text.Json; +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.PushChannels; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Worker.Services.Push; +using Hrynco.RabbitMq; +using NSubstitute; + +namespace HrynCo.NotificationService.Services.Tests.Push; + +public sealed class PushPayloadVersionTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void OriginalPayloadDeserializesWithoutTemplateFieldsAndKeepsExactLegacyOutput(bool explicitVersion) + { + var json = JsonSerializer.SerializeToNode(new + { + registrationId = Guid.NewGuid(), bindingId = Guid.NewGuid(), userId = Guid.NewGuid(), + serviceName = "Invemory", title = "Original title", body = "Original body", + token = "device-token", tokenHash = new string('a', 64), expiresAt = DateTimeOffset.UtcNow.AddMinutes(5) + })!; + if (explicitVersion) json["version"] = 1; + var data = json.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + var message = new SendPushMessage { Data = data, CorrelationContext = new CorrelationContext { CorrelationId = Guid.NewGuid().ToString() } }; + Assert.True(PushMessageValidator.IsValid(message)); + Assert.Null(data.TemplateData); + var id = Guid.NewGuid(); + var payload = FirebasePushClient.BuildPayload(id, new RenderedPushMessageData(data, data.Title!, data.Body!, null, [])); + Assert.Equal(7, payload.Count); + Assert.Equal("1", payload["version"]); + Assert.Equal("/home", payload["route"]); + Assert.Equal(id.ToString("D"), payload["messageId"]); + Assert.Equal(data.BindingId.ToString("D"), payload["bindingId"]); + Assert.Equal("Original title", payload["title"]); + Assert.Equal("Original body", payload["body"]); + Assert.False(payload.ContainsKey("actions")); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + [InlineData(-1)] + public async Task UnsupportedVersionNeverFallsBackOrContactsFirebase(int version) + { + var data = Data() with { Version = version, Title = "Legacy", Body = "Legacy" }; + Assert.False(PushMessageValidator.IsValidContent(data)); + var client = Substitute.For(); + var sender = new FcmPushSender(Substitute.For(), Substitute.For(), client, Substitute.For()); + var result = await sender.SendAsync(Guid.NewGuid(), data, default); + Assert.Contains("Unsupported push payload version", result.Error); + await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default); + Assert.Throws(() => FirebasePushClient.BuildPayload(Guid.NewGuid(), new RenderedPushMessageData(data, "Title", "Body", null, []))); + } + + [Fact] + public void SectionActionHasNoObjectIdAndVersionTwoNeverContainsLegacyRoute() + { + var template = new PushTemplateDefinition { Key = "Section", Languages = [new PushTemplateTranslation + { + LanguageCode = "uk", Title = "Зміни", Body = "Перегляньте зміни", + ActionsJson = "{\"defaultAction\":\"openBasket\",\"buttons\":[{\"action\":\"openBasket\",\"label\":\"Перейти в розділ\"}]}" + }] }; + var data = Data(); + var rendered = PushTemplateRenderer.Render(template, data); + Assert.Null(Assert.Single(rendered.Buttons).ObjectId); + var payload = FirebasePushClient.BuildPayload(Guid.NewGuid(), rendered); + Assert.Equal("2", payload["version"]); + Assert.False(payload.ContainsKey("route")); + using var actions = JsonDocument.Parse(payload["actions"]); + var button = actions.RootElement.GetProperty("buttons")[0]; + Assert.Equal("openBasket", button.GetProperty("action").GetString()); + Assert.Equal("Перейти в розділ", button.GetProperty("label").GetString()); + Assert.False(button.TryGetProperty("objectId", out _)); + Assert.Throws(() => PushTemplateRenderer.Render(template, data with { TemplateData = data.TemplateData! with { Actions = new Dictionary() } })); + } + + [Fact] + public void TestSendBuildsActualButtonPayloadWithRealBindingAndPreservesSimpleLegacyTest() + { + var binding = Guid.NewGuid(); + var test = new PushChannelTestMessage { Token = "test-token", Version = 2, BindingId = binding, ActionKey = "openBasket", ButtonLabel = "Перейти в розділ" }; + var current = FirebasePushClient.BuildTestMessage(test); + Assert.Null(current.Notification); + Assert.Equal("2", current.Data["version"]); + Assert.Equal(binding.ToString("D"), current.Data["bindingId"]); + using var actions = JsonDocument.Parse(current.Data["actions"]); + Assert.Equal("openBasket", actions.RootElement.GetProperty("buttons")[0].GetProperty("action").GetString()); + Assert.False(actions.RootElement.GetProperty("buttons")[0].TryGetProperty("objectId", out _)); + var legacy = FirebasePushClient.BuildTestMessage(test with { Version = 1, BindingId = null }); + Assert.NotNull(legacy.Notification); + Assert.True(legacy.Data is null || legacy.Data.Count == 0); + Assert.NotNull((test with { BindingId = null }).Validate()); + Assert.NotNull((test with { Version = 9 }).Validate()); + } + + private static SendPushMessageData Data() => new() + { + RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), ServiceName = "Invemory", + Version = 2, TemplateKey = "Section", LanguageCode = "uk", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), + TemplateData = new PushTemplateData { Bindings = new Dictionary(), Actions = new Dictionary { ["openBasket"] = new() } } + }; +} diff --git a/HrynCo.NotificationService.Services.Tests/Push/PushTemplateRendererTests.cs b/HrynCo.NotificationService.Services.Tests/Push/PushTemplateRendererTests.cs new file mode 100644 index 0000000..998fa62 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/Push/PushTemplateRendererTests.cs @@ -0,0 +1,90 @@ +using System.Text.Json; +using HrynCo.NotificationService.Contracts.Messages; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Worker.Services.Push; + +namespace HrynCo.NotificationService.Services.Tests.Push; + +public sealed class PushTemplateRendererTests +{ + [Fact] + public void RendersLocalizedLabelsAndOpaqueObjectIdsWithoutReinterpretingValues() + { + var result = PushTemplateRenderer.Render(Template(), Message()); + Assert.Equal("Hello {{Injected}}", result.Title); + var button = Assert.Single(result.Buttons); + Assert.Equal("View {{Injected}}", button.Label); + Assert.Equal("need-42", button.ObjectId); + Assert.Equal("open", result.DefaultAction!.Action); + using var payload = JsonDocument.Parse(FirebasePushClient.BuildPayload(Guid.NewGuid(), result)["actions"]); + Assert.Equal("need-42", payload.RootElement.GetProperty("buttons")[0].GetProperty("objectId").GetString()); + Assert.Equal("open", payload.RootElement.GetProperty("defaultAction").GetProperty("action").GetString()); + } + + [Fact] + public void UsesEnglishFallbackAndRejectsMissingFallback() + { + var message = Message() with { LanguageCode = "uk" }; + Assert.Equal("Hello {{Injected}}", PushTemplateRenderer.Render(Template(), message).Title); + var template = Template(); + template.Languages = []; + Assert.Throws(() => PushTemplateRenderer.Render(template, message)); + } + + [Fact] + public void MissingBindingOrConfiguredActionFailsInsteadOfSendingIncompleteNotification() + { + var message = Message(); + Assert.Throws(() => PushTemplateRenderer.Render(Template(), message with + { + TemplateData = message.TemplateData! with { Bindings = new Dictionary() } + })); + Assert.Throws(() => PushTemplateRenderer.Render(Template(), message with + { + TemplateData = message.TemplateData! with { Actions = new Dictionary() } + })); + } + + [Fact] + public void TruncationKeepsUnicodeTextElementsIntact() + { + var message = Message() with + { + TemplateData = Message().TemplateData! with { Bindings = new Dictionary { ["Name"] = string.Concat(Enumerable.Repeat("😀", 100)) } } + }; + var result = PushTemplateRenderer.Render(Template(), message); + Assert.True(result.Title.Length <= 100); + Assert.EndsWith("…", result.Title); + Assert.False(char.IsHighSurrogate(result.Title[^2])); + } + + [Theory] + [InlineData("{\"buttons\":null}")] + [InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"\"}]}")] + [InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"Open\"},{\"action\":\"open\",\"label\":\"Again\"}]}")] + [InlineData("{\"defaultAction\":\"https://example.invalid\"}")] + [InlineData("{\"unknown\":true}")] + public void RejectsInvalidActionDefinitions(string json) => + Assert.Throws(() => PushTemplateActionValidation.Parse(json)); + + private static PushTemplateDefinition Template() => new() + { + Key = "Shared", Variables = [new EmailTemplateVariable { Name = "Name", Required = true }], + Languages = [new PushTemplateTranslation + { + LanguageCode = "en", Title = "Hello {{Name}}", Body = "Shared item", + ActionsJson = "{\"defaultAction\":\"open\",\"buttons\":[{\"action\":\"open\",\"label\":\"View {{Name}}\"}]}" + }] + }; + + private static SendPushMessageData Message() => new() + { + RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), + ServiceName = "Example", Version = 2, TemplateKey = "Shared", LanguageCode = "en", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5), + TemplateData = new PushTemplateData + { + Bindings = new Dictionary { ["Name"] = "{{Injected}}" }, + Actions = new Dictionary { ["open"] = new() { ObjectId = "need-42" } } + } + }; +} diff --git a/HrynCo.NotificationService.Services.Tests/Push/SendPushDeliveryValidatorTests.cs b/HrynCo.NotificationService.Services.Tests/Push/SendPushDeliveryValidatorTests.cs index 0963d70..4b36cad 100644 --- a/HrynCo.NotificationService.Services.Tests/Push/SendPushDeliveryValidatorTests.cs +++ b/HrynCo.NotificationService.Services.Tests/Push/SendPushDeliveryValidatorTests.cs @@ -27,7 +27,7 @@ public sealed class SendPushDeliveryValidatorTests Data = new SendPushMessageData { RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), - ServiceName = "Invemory", Title = "Test", Body = "Test", Token = "test-device-token", + ServiceName = "Invemory", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary(), Actions = new Dictionary() }, Token = "test-device-token", TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10) } }; diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs index 4286012..6382375 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs @@ -102,7 +102,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass await using var context = await DefinitionApiTestContext.CreateAsync(fixture); var root = $"/admin/services/{context.ServiceId}"; var templates = await context.Client.GetStringAsync(root); - Assert.Contains("No email templates for this service yet.", templates); + Assert.Contains("No templates for this service yet.", templates); Assert.Contains("Create First Template", templates); Assert.Contains(root + "/templates/create", templates); Assert.Contains("template-key-filter", templates); @@ -129,7 +129,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass Assert.Contains("Could not load templates", html); Assert.Contains("Templates are unavailable", html); Assert.DoesNotContain("Create First Template", html); - Assert.DoesNotContain("No email templates for this service yet.", html); + Assert.DoesNotContain("No templates for this service yet.", html); } private static void AssertConfirmationReset(string html) => diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs index 34f819a..d6fe7f9 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs @@ -8,7 +8,7 @@ public sealed class AdminTemplatesIndexViewTests string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml")); Assert.Contains("asp-route-serviceId=\"@serviceId\"", view); Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view); - Assert.Contains("new { serviceId, definitionId = template.Id, keyFilter }", view); + Assert.Contains("new { serviceId, definitionId = email.Id, keyFilter }", view); Assert.DoesNotContain("/admin/templates", view); } } diff --git a/HrynCo.NotificationService.Web.IntegrationTests/ChannelTemplateModelTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/ChannelTemplateModelTests.cs new file mode 100644 index 0000000..a702316 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/ChannelTemplateModelTests.cs @@ -0,0 +1,153 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.DAL.EF; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class ChannelTemplateModelTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + [Fact] + public async Task MigrationPreservesEmailAndSupportsAllChannelCombinations() + { + var connection = await fixture.CreateDatabaseAsync(legacy: true); + var originalId = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", "[{\"name\":\"Name\",\"required\":true}]"); + await using var db = TemplateDatabaseFixture.CreateContext(connection); + await db.Database.MigrateAsync(); + await db.Database.MigrateAsync(); + Assert.False(db.Database.HasPendingModelChanges()); + using var provider = Provider(connection); + using var scope = provider.CreateScope(); + var emails = scope.ServiceProvider.GetRequiredService(); + var original = await emails.GetAsync("TestService", "Welcome", "en"); + Assert.Equal(originalId, original!.Id); + Assert.True(Assert.Single(original.Variables).Required); + await SeedPushAsync(connection, "Welcome"); + await SeedPushAsync(connection, "PushOnly"); + Assert.Null(await emails.GetAsync("TestService", "PushOnly", "en")); + var definitions = scope.ServiceProvider.GetRequiredService(); + await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "EmailOnly" }, true); + await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "PushOnly" }, true); + await TemplateDatabaseFixture.ExecuteAsync(connection, """ + DO $$ BEGIN + IF (SELECT count(*) FROM templates) <> 3 OR (SELECT count(*) FROM push_template_definitions) <> 2 THEN + RAISE EXCEPTION 'Unexpected shared catalog or channel count'; + END IF; + IF (SELECT variables FROM email_template_definitions WHERE key = 'Welcome') = + (SELECT variables FROM push_template_definitions WHERE key = 'Welcome') THEN + RAISE EXCEPTION 'Channel variables must remain independent'; + END IF; + END $$; + """); + } + + [Fact] + public async Task DeletingEmailRetainsPushAndBlocksServiceDeletion() + { + var connection = await fixture.CreateDatabaseAsync(); + using var provider = Provider(connection); + using var scope = provider.CreateScope(); + var emails = scope.ServiceProvider.GetRequiredService(); + var email = new EmailTemplateDefinition { ServiceName = "TestService", Key = "Shared" }; + await emails.SaveDefinitionAsync(email, true); + await SeedPushAsync(connection, "Shared"); + Assert.True(await emails.DeleteDefinitionAsync(email.ServiceId == Guid.Empty + ? (await scope.ServiceProvider.GetRequiredService().GetByNameAsync("TestService"))!.Id + : email.ServiceId, email.Key, email.Revision)); + await TemplateDatabaseFixture.ExecuteAsync(connection, """ + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM templates WHERE key = 'Shared') OR + NOT EXISTS (SELECT 1 FROM push_templates) THEN + RAISE EXCEPTION 'Email deletion removed push'; + END IF; + END $$; + """); + using var readScope = provider.CreateScope(); + var services = readScope.ServiceProvider.GetRequiredService(); + var service = (await services.GetByNameAsync("TestService"))!; + await Assert.ThrowsAsync(() => services.DeleteAsync(service.Id, service.Revision)); + } + + [Fact] + public async Task LanguageDeletionCountsBothChannelsAndRetainsDefinitions() + { + var connection = await fixture.CreateDatabaseAsync(); + await SeedPushAsync(connection, "PushOnly"); + using var provider = Provider(connection); + using var scope = provider.CreateScope(); + var services = scope.ServiceProvider.GetRequiredService(); + var service = (await services.GetByNameAsync("TestService"))!; + var language = service.Languages.Single(l => l.Code == "en"); + Assert.Equal(1, language.VersionCount); + service.Languages = service.Languages.Where(l => l.Id != language.Id).ToList(); + await Assert.ThrowsAsync(() => services.SaveAsync(service, false)); + await services.RemoveLanguageAsync(service.Id, language.Id, 1, service.Revision); + await TemplateDatabaseFixture.ExecuteAsync(connection, """ + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM push_templates) OR + NOT EXISTS (SELECT 1 FROM push_template_definitions WHERE key = 'PushOnly') THEN + RAISE EXCEPTION 'Language deletion did not retain the empty push definition'; + END IF; + END $$; + """); + } + + [Fact] + public async Task DatabaseRejectsDuplicatePushLanguageAndForeignServiceLanguage() + { + var connection = await fixture.CreateDatabaseAsync(); + await SeedPushAsync(connection, "PushOnly"); + await AssertSqlStateAsync(connection, "23505", """ + INSERT INTO push_templates + SELECT gen_random_uuid(), definition_id, service_id, service_language_id, title, body, created, updated + FROM push_templates LIMIT 1; + """); + await TemplateDatabaseFixture.ExecuteAsync(connection, """ + INSERT INTO services (id, name, revision, created) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Other', gen_random_uuid(), now()); + INSERT INTO service_languages (id, service_id, code, created) VALUES + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'en', now()); + """); + await AssertSqlStateAsync(connection, "23503", """ + UPDATE push_templates SET service_language_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; + """); + } + + [Fact] + public async Task RollbackRefusesToDiscardPushDefinitions() + { + var connection = await fixture.CreateDatabaseAsync(); + await SeedPushAsync(connection, "PushOnly"); + await using var db = TemplateDatabaseFixture.CreateContext(connection); + var error = await Assert.ThrowsAsync(() => db.GetService() + .MigrateAsync("20261024080457_AddPushChannels")); + Assert.Contains("Cannot roll back channel templates", error.MessageText); + await db.Database.MigrateAsync(); + } + + private static Task SeedPushAsync(string connection, string key) => TemplateDatabaseFixture.ExecuteAsync(connection, $""" + INSERT INTO templates (id, service_id, key, created) + SELECT gen_random_uuid(), id, '{key}', now() FROM services WHERE name = 'TestService' + ON CONFLICT (service_id, key) DO NOTHING; + INSERT INTO push_template_definitions (id, service_id, key, revision, variables, created) + SELECT gen_random_uuid(), service_id, key, gen_random_uuid(), '[]'::jsonb, now() + FROM templates WHERE key = '{key}'; + INSERT INTO push_templates (id, definition_id, service_id, service_language_id, title, body, created) + SELECT gen_random_uuid(), d.id, d.service_id, l.id, 'Title', 'Body', now() + FROM push_template_definitions d JOIN service_languages l ON l.service_id = d.service_id + WHERE d.key = '{key}' AND l.code = 'en'; + """); + + private static ServiceProvider Provider(string connection) => + new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider(); + + private static async Task AssertSqlStateAsync(string connection, string state, string sql) + { + var error = await Assert.ThrowsAsync(() => TemplateDatabaseFixture.ExecuteAsync(connection, sql)); + Assert.Equal(state, error.SqlState); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs index 3e4877e..1c22fd1 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs @@ -22,8 +22,8 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF { (Url: "/admin/services/create", Title: "Create Service", Form: "serviceForm"), (Url: root + "/edit", Title: "Edit Service", Form: "serviceForm"), - (Url: root + "/templates/create", Title: "Create Email Template", Form: "templateForm"), - (Url: root + "/templates/" + definition.Id, Title: "Edit Email Template", Form: "templateForm") + (Url: root + "/templates/create", Title: "Create Template", Form: "templateForm"), + (Url: root + "/templates/" + definition.Id, Title: "Template: Layout", Form: "templateForm") }; foreach (var editor in editors) AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form); @@ -32,7 +32,7 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF fields["VariablesJson"] = "{invalid"; var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync(); Assert.Contains("Invalid variables JSON", errorHtml); - AssertEditorLayout(errorHtml, "Create Email Template", "templateForm"); + AssertEditorLayout(errorHtml, "Create Template", "templateForm"); Assert.Contains("
", errorHtml); Assert.Contains("
Service
", errorHtml); Assert.Contains("
TestService
", errorHtml); diff --git a/HrynCo.NotificationService.Web.IntegrationTests/PushChannelFormatTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/PushChannelFormatTests.cs new file mode 100644 index 0000000..493988b --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/PushChannelFormatTests.cs @@ -0,0 +1,55 @@ +using System.Net.Http.Json; +using System.Text.Json; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.PushChannels; +using HrynCo.NotificationService.Worker.Services.Push; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class PushChannelFormatTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + [Theory] + [InlineData(1, false, true)] + [InlineData(2, true, true)] + [InlineData(2, false, false)] + [InlineData(3, true, false)] + public async Task TestEndpointSelectsFormatAndValidatesBeforeProviderWithoutChangingChannel(int version, bool hasBinding, bool accepted) + { + await using var factory = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync()); + var firebase = new RecordingFirebasePushClient(); + await using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddSingleton(firebase); + })); + using var client = app.CreateClient(); + var html = await client.GetStringAsync("/admin/push-channels/create"); + Assert.Contains("Version 2 — notification with a button", html); + var fields = TemplateEditorTestFields.Read(html); + var binding = hasBinding ? Guid.NewGuid() : (Guid?)null; + using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/test-send"); + request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]); + request.Content = JsonContent.Create(new + { + firebaseProjectId = "test-project", credentialsJson = PushChannelTestCredentials.Create(), + recipientToken = "test-token", version, bindingId = binding, actionKey = "openBasket", buttonLabel = "Перейти в розділ" + }); + var response = await client.SendAsync(request); + var result = await response.Content.ReadFromJsonAsync(); + Assert.Equal(accepted, result.GetProperty("success").GetBoolean()); + Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync()); + if (accepted) + { + var sent = Assert.Single(firebase.Messages); + Assert.Equal(version, sent.Version); + Assert.Equal(binding, sent.BindingId); + Assert.Equal("Перейти в розділ", sent.ButtonLabel); + } + else Assert.Empty(firebase.Messages); + using var scope = app.Services.CreateScope(); + Assert.Empty(await scope.ServiceProvider.GetRequiredService().GetAllAsync()); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/PushTemplateEditorTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/PushTemplateEditorTests.cs new file mode 100644 index 0000000..e3e139e --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/PushTemplateEditorTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.DAL.EF; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class PushTemplateEditorTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + [Fact] + public async Task PushOnlyKeyCanAcquireEmailWithoutChangingPush_AndListHasOneRow() + { + var connection = await fixture.CreateDatabaseAsync(); + await using var app = new TemplateWebApplicationFactory(connection); + using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var service = await ReadServiceAsync(connection); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create")); + fields["Key"] = "PushOnly"; + SetPush(fields, "en", "Hello {{Name}}", "A shared item changed."); + fields["VariablesJson"] = "[{\"name\":\"Name\",\"required\":true}]"; + Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields))).StatusCode); + var missing = await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/email?key=PushOnly"); + Assert.Contains("Create Email Template", missing); + var emailFields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/templates/create?templateKey=PushOnly")); + Assert.Equal("PushOnly", emailFields["Key"]); + Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/templates/save", new FormUrlEncodedContent(emailFields))).StatusCode); + using var provider = Provider(connection); + using var scope = provider.CreateScope(); + var push = await scope.ServiceProvider.GetRequiredService().GetAsync(service.Id, "PushOnly"); + Assert.Equal("Hello {{Name}}", Assert.Single(push!.Languages).Title); + var email = await scope.ServiceProvider.GetRequiredService().GetDefinitionAsync(service.Id, "PushOnly"); + Assert.Empty(email!.Variables); + var list = await client.GetStringAsync($"/admin/services/{service.Id}"); + Assert.Contains("Email", list); + Assert.Contains("Push", list); + Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(list, "PushOnly").Count); + } + + [Theory] + [InlineData("partial")] + [InlineData("placeholder")] + [InlineData("json")] + [InlineData("catalog")] + public async Task InvalidSaveRetainsInputAndDoesNotCreatePush(string scenario) + { + var connection = await fixture.CreateDatabaseAsync(); + await using var app = new TemplateWebApplicationFactory(connection); + using var client = app.CreateClient(); + var service = await ReadServiceAsync(connection); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create")); + fields["Key"] = "Invalid"; + SetPush(fields, "en", "Retained title", "Body"); + if (scenario == "partial") SetPush(fields, "en", "Retained title", ""); + if (scenario == "placeholder") SetPush(fields, "en", "Retained title", "{{Unknown}}"); + if (scenario == "json") fields["VariablesJson"] = "{}"; + if (scenario == "catalog") fields["Languages[0].LanguageCode"] = "foreign"; + var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields)); + var html = await response.Content.ReadAsStringAsync(); + Assert.Contains("Retained title", html); + Assert.Contains("validation-summary-errors", html); + using var provider = Provider(connection); + using var scope = provider.CreateScope(); + Assert.Null(await scope.ServiceProvider.GetRequiredService().GetAsync(service.Id, "Invalid")); + } + + [Fact] + public async Task MissingTabHasNoSideEffects_StaleSaveIsRejected_AndFinalVersionCanBeRemoved() + { + var connection = await fixture.CreateDatabaseAsync(); + await using var app = new TemplateWebApplicationFactory(connection); + using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var service = await ReadServiceAsync(connection); + Assert.Contains("Create Push Template", await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared")); + using (var provider = Provider(connection)) + using (var scope = provider.CreateScope()) + Assert.Empty(await scope.ServiceProvider.GetRequiredService().GetDefinitionsAsync(service.Id)); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create?key=Shared")); + SetPush(fields, "uk", "Title", "Body"); + await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields)); + var saved = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared")); + var stale = new Dictionary(saved); + SetPush(saved, "uk", "Updated title", "Updated body"); + Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(saved))).StatusCode); + SetPush(stale, "uk", "Retained stale input", "Body"); + var error = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(stale)); + Assert.Contains("changed after you opened", await error.Content.ReadAsStringAsync()); + Assert.Contains("Retained stale input", await error.Content.ReadAsStringAsync()); + var current = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared")); + SetPush(current, "uk", "", ""); + Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(current))).StatusCode); + using var finalProvider = Provider(connection); + using var finalScope = finalProvider.CreateScope(); + Assert.Empty((await finalScope.ServiceProvider.GetRequiredService().GetAsync(service.Id, "Shared"))!.Languages); + } + + [Fact] + public async Task ConfirmedPushDeletionKeepsEmail_AndPostRequiresAntiforgery() + { + var connection = await fixture.CreateDatabaseAsync(); + var service = await ReadServiceAsync(connection); + using (var provider = Provider(connection)) + using (var scope = provider.CreateScope()) + { + await scope.ServiceProvider.GetRequiredService().SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Shared" }, true); + await scope.ServiceProvider.GetRequiredService().SaveAsync(new PushTemplateDefinition { ServiceId = service.Id, Key = "Shared" }, true); + } + await using var app = new TemplateWebApplicationFactory(connection); + using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var route = $"/admin/services/{service.Id}/push-templates/delete"; + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(route, new FormUrlEncodedContent(new Dictionary { ["Key"] = "Shared" }))).StatusCode); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync(route + "?key=Shared")); + fields["ConfirmDelete"] = "true"; + Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(route, new FormUrlEncodedContent(fields))).StatusCode); + using var readProvider = Provider(connection); + using var readScope = readProvider.CreateScope(); + Assert.NotNull(await readScope.ServiceProvider.GetRequiredService().GetDefinitionAsync(service.Id, "Shared")); + Assert.Null(await readScope.ServiceProvider.GetRequiredService().GetAsync(service.Id, "Shared")); + } + + private static ServiceProvider Provider(string connection) => new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider(); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LocalizedActionsRoundTripAndInvalidEditsRetainInput(bool invalid) + { + var connection = await fixture.CreateDatabaseAsync(); + await using var app = new TemplateWebApplicationFactory(connection); + using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var service = await ReadServiceAsync(connection); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create")); + fields["Key"] = "WithButtons"; + SetPush(fields, "uk", "Потребою поділилися", "Перегляньте деталі"); + var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key; + var actionField = codeField[..^"LanguageCode".Length] + "ActionsJson"; + fields[actionField] = invalid ? "{\"buttons\":null}" : "{\"defaultAction\":\"openNeed\",\"buttons\":[{\"action\":\"openNeed\",\"label\":\"Переглянути\"}]}"; + var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields)); + using var provider = Provider(connection); + using var scope = provider.CreateScope(); + var saved = await scope.ServiceProvider.GetRequiredService().GetAsync(service.Id, "WithButtons"); + if (invalid) + { + Assert.Null(saved); + Assert.Contains("validation-summary-errors", await response.Content.ReadAsStringAsync()); + Assert.Equal(fields[actionField], TemplateEditorTestFields.Read(await response.Content.ReadAsStringAsync())[actionField].Trim()); + return; + } + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + var actions = PushTemplateActionValidation.Parse(Assert.Single(saved!.Languages).ActionsJson); + Assert.Equal("openNeed", actions.DefaultAction); + Assert.Equal("Переглянути", Assert.Single(actions.Buttons).Label); + var reloaded = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=WithButtons")); + var reloadedCodeField = reloaded.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key; + Assert.Equal("openNeed", PushTemplateActionValidation.Parse(reloaded[reloadedCodeField[..^"LanguageCode".Length] + "ActionsJson"]).DefaultAction); + } + + private static async Task ReadServiceAsync(string connection) + { + using var provider = Provider(connection); + using var scope = provider.CreateScope(); + return (await scope.ServiceProvider.GetRequiredService().GetByNameAsync("TestService"))!; + } + + private static void SetPush(Dictionary fields, string code, string title, string body) + { + var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == code).Key; + var prefix = codeField[..^"LanguageCode".Length]; + fields[prefix + "Title"] = title; + fields[prefix + "Body"] = body; + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/RecordingFirebasePushClient.cs b/HrynCo.NotificationService.Web.IntegrationTests/RecordingFirebasePushClient.cs new file mode 100644 index 0000000..10ac04d --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/RecordingFirebasePushClient.cs @@ -0,0 +1,22 @@ +using HrynCo.NotificationService.DAL.Abstract.Push; +using HrynCo.NotificationService.DAL.Abstract.PushChannels; +using HrynCo.NotificationService.Worker.Services.Push; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class RecordingFirebasePushClient : IFirebasePushClient +{ + public List Messages { get; } = []; + + public Task SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) + { + Messages.Add(message); + return Task.FromResult(new PushDeliveryOutcome(null)); + } + + public Task CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) => + Task.FromResult(new PushDeliveryOutcome(null)); + + public Task SendAsync(Guid messageId, RenderedPushMessageData data, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) => + throw new NotSupportedException(); +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs index 85c945f..8dee98e 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs @@ -116,7 +116,7 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix var empty = await client.GetStringAsync(baseUrl); Assert.Contains("No languages configured for this service.", empty); Assert.Contains("template-key-filter", empty); - Assert.Contains("No email templates for this service yet.", empty); + Assert.Contains("No templates for this service yet.", empty); await scope.ServiceProvider.GetRequiredService() .SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true); var withDefinition = await client.GetStringAsync(baseUrl); diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminPushTemplatesController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminPushTemplatesController.cs new file mode 100644 index 0000000..037545a --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminPushTemplatesController.cs @@ -0,0 +1,162 @@ +using System.Text.Json; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; +using Microsoft.AspNetCore.Mvc; + +namespace HrynCo.NotificationService.Web.Controllers.Admin; + +[Route("admin/services/{serviceId:guid}/push-templates")] +public sealed class AdminPushTemplatesController(IPushTemplateDefinitionRepository pushes, + IEmailTemplateDefinitionRepository emails, INotificationServiceRepository services, + ILogger logger) : Controller +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; + + [HttpGet("email")] + public async Task Email([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct) + { + var service = await services.GetAsync(serviceId, ct); + if (service is null) return NotFound(); + if (string.IsNullOrEmpty(key)) + return RedirectToAction("Create", "AdminTemplates", new { serviceId, keyFilter }); + var email = await emails.GetDefinitionAsync(serviceId, key, ct); + if (email is not null) + return RedirectToAction("Edit", "AdminTemplates", new { serviceId, definitionId = email.Id, keyFilter }); + return Missing(service, key, keyFilter, false); + } + + [HttpGet("edit")] + public async Task Edit([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct) + { + var service = await services.GetAsync(serviceId, ct); + if (service is null) return NotFound(); + if (string.IsNullOrEmpty(key)) + return RedirectToAction(nameof(Create), new { serviceId, keyFilter }); + var definition = await pushes.GetAsync(serviceId, key, ct); + return definition is null ? Missing(service, key, keyFilter, true) + : Editor(ToModel(definition, service, keyFilter), service); + } + + [HttpGet("create")] + public async Task Create([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct) + { + var service = await services.GetAsync(serviceId, ct); + if (service is null) return NotFound(); + if (key is not null && await pushes.GetAsync(serviceId, key, ct) is not null) + return RedirectToAction(nameof(Edit), new { serviceId, key, keyFilter }); + return Editor(new PushTemplateEditViewModel + { + ServiceId = serviceId, ServiceName = service.Name, Key = key ?? "", KeyFilter = keyFilter, + LockKey = key is not null && await emails.GetDefinitionAsync(serviceId, key, ct) is not null + }, service); + } + + [HttpPost("save"), ValidateAntiForgeryToken] + public async Task Save([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct) + { + var service = await services.GetAsync(serviceId, ct); + if (service is null || serviceId != model.ServiceId) return NotFound(); + model.ServiceName = service.Name; + var persisted = await pushes.GetAsync(serviceId, model.Key, ct); + if (model.Id is not null && persisted?.Id != model.Id) return NotFound(); + model.LockKey = model.Id is not null || await emails.GetDefinitionAsync(serviceId, model.Key, ct) is not null; + SetSavedStatuses(model, persisted); + ValidateCatalog(model, service); + if (!ModelState.IsValid) return Editor(model, service); + return await SaveModelAsync(model, service, ct); + } + + private async Task SaveModelAsync(PushTemplateEditViewModel model, NotificationServiceInfo service, CancellationToken ct) + { + try + { + await pushes.SaveAsync(new PushTemplateDefinition + { + Id = model.Id ?? Guid.Empty, ServiceId = service.Id, Key = model.Key, Revision = model.Revision, + Variables = EmailTemplateValidation.ParseVariables(model.VariablesJson), + Languages = model.Languages.Where(l => l.HasContent).Select(l => new PushTemplateTranslation + { + LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson + }).ToList() + }, model.IsNew, ct); + return RedirectToAction("Index", "AdminTemplates", new { serviceId = service.Id, key = model.KeyFilter }); + } + catch (JsonException) { ModelState.AddModelError("VariablesJson", "Invalid variables JSON. Use name and required properties."); } + catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); } + catch (Exception error) when (error is not OperationCanceledException) + { + logger.LogError(error, "Failed to save push template definition"); + ModelState.AddModelError("", "Could not save the push template. Your entered values are retained; try again."); + } + return Editor(model, service); + } + + [HttpGet("delete")] + public async Task ConfirmDelete([FromRoute] Guid serviceId, string key, string? keyFilter, CancellationToken ct) + { + var service = await services.GetAsync(serviceId, ct); + var definition = await pushes.GetAsync(serviceId, key, ct); + return service is null || definition is null ? NotFound() : View("Delete", ToModel(definition, service, keyFilter)); + } + + [HttpPost("delete"), ValidateAntiForgeryToken] + public async Task Delete([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct) + { + var service = await services.GetAsync(serviceId, ct); + var definition = await pushes.GetAsync(serviceId, model.Key, ct); + if (service is null || definition is null) return NotFound(); + if (!model.ConfirmDelete || !ModelState.IsValid) + ModelState.AddModelError("", "Confirm deletion of this push template to continue."); + else + { + try + { + await pushes.DeleteAsync(serviceId, model.Key, model.Revision, ct); + return RedirectToAction("Index", "AdminTemplates", new { serviceId, key = model.KeyFilter }); + } + catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); } + } + var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList(); + ModelState.Clear(); + foreach (var error in errors) ModelState.AddModelError("", error); + return View("Delete", ToModel(definition, service, model.KeyFilter)); + } + + private IActionResult Missing(NotificationServiceInfo service, string key, string? keyFilter, bool isPush) => View("Missing", new TemplateChannelNavigationViewModel + { + ServiceId = service.Id, ServiceName = service.Name, Key = key, KeyFilter = keyFilter, IsPush = isPush + }); + + private IActionResult Editor(PushTemplateEditViewModel model, NotificationServiceInfo service) + { + foreach (var language in service.Languages) + if (!model.Languages.Any(l => l.LanguageCode == language.Code)) + model.Languages.Add(new PushTemplateLanguageEditViewModel { LanguageCode = language.Code }); + return View("Edit", model); + } + + private void ValidateCatalog(PushTemplateEditViewModel model, NotificationServiceInfo service) + { + var codes = model.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal); + if (codes.Count != model.Languages.Count || !codes.SetEquals(service.Languages.Select(l => l.Code))) + ModelState.AddModelError("", "The service language catalog changed or the submitted tabs are invalid. Reload before saving."); + } + + private static void SetSavedStatuses(PushTemplateEditViewModel model, PushTemplateDefinition? persisted) + { + var codes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal) ?? []; + foreach (var language in model.Languages) language.IsPresent = codes.Contains(language.LanguageCode); + } + + private static PushTemplateEditViewModel ToModel(PushTemplateDefinition definition, NotificationServiceInfo service, string? keyFilter) => new() + { + Id = definition.Id, ServiceId = service.Id, ServiceName = service.Name, Key = definition.Key, + Revision = definition.Revision, KeyFilter = keyFilter, LockKey = true, + VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions), + Languages = definition.Languages.Select(l => new PushTemplateLanguageEditViewModel + { + LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson, IsPresent = true + }).ToList() + }; +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs index 441a735..5a121f4 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminTemplatesController.cs @@ -12,7 +12,7 @@ namespace HrynCo.NotificationService.Web.Controllers.Admin; [Route("admin/services/{serviceId:guid}/templates")] public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions, - ILogger logger, INotificationServiceRepository services) : Controller + ILogger logger, INotificationServiceRepository services, IPushTemplateDefinitionRepository pushes) : Controller { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; @@ -27,6 +27,8 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD ModelState.AddModelError("", previousError); try { + model.PushDefinitions = (await pushes.GetDefinitionsAsync(serviceId, ct)) + .Where(d => string.IsNullOrWhiteSpace(key) || d.Key == key).ToList(); model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct)) .Where(d => d.ServiceId == serviceId).ToList(); } @@ -39,12 +41,12 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD } [HttpGet("create")] - public async Task Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct) + public async Task Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct, [FromQuery] string? templateKey = null) { var service = await services.GetAsync(serviceId, ct); return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel { - ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter + ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter, Key = templateKey ?? "", LockKey = !string.IsNullOrEmpty(templateKey) }, service); } @@ -72,6 +74,7 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD var persisted = model.Id is Guid definitionId ? await OwnedDefinitionAsync(service, definitionId, ct) : null; if (model.Id is not null && persisted is null) return NotFound(); + model.LockKey = model.Id is not null || await pushes.GetAsync(serviceId, model.Key, ct) is not null; var presentCodes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal); foreach (var language in model.Languages) language.IsPresent = presentCodes?.Contains(language.LanguageCode) == true; diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs index fd9e100..6ccf9e9 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateEditViewModel.cs @@ -16,6 +16,7 @@ public sealed class EmailTemplateEditViewModel public List Languages { get; set; } = []; public int ActiveLanguageIndex { get; set; } public string? KeyFilter { get; set; } + public bool LockKey { get; set; } public bool IsNew => Id == null; - public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template"; + public string PageTitle => IsNew ? "Create Template" : "Template: " + Key; } diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateEditViewModel.cs new file mode 100644 index 0000000..6fac1bd --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateEditViewModel.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; + +namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; + +public sealed class PushTemplateEditViewModel +{ + public Guid? Id { get; set; } + public Guid ServiceId { get; set; } + public Guid Revision { get; set; } + public string ServiceName { get; set; } = ""; + [Required, StringLength(100)] + public string Key { get; set; } = ""; + [Required] + public string VariablesJson { get; set; } = "[]"; + public List Languages { get; set; } = []; + public int ActiveLanguageIndex { get; set; } + public string? KeyFilter { get; set; } + public bool LockKey { get; set; } + public bool ConfirmDelete { get; set; } + public bool IsNew => Id is null; + public string PageTitle => IsNew ? "Create Template" : "Template: " + Key; +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateLanguageEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateLanguageEditViewModel.cs new file mode 100644 index 0000000..bb29564 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushTemplateLanguageEditViewModel.cs @@ -0,0 +1,38 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; + +public sealed class PushTemplateLanguageEditViewModel : IValidatableObject +{ + [Required, StringLength(10)] + public string LanguageCode { get; set; } = ""; + [DisplayFormat(ConvertEmptyStringToNull = false)] + public string Title { get; set; } = ""; + [DisplayFormat(ConvertEmptyStringToNull = false)] + public string Body { get; set; } = ""; + [Required] + public string ActionsJson { get; set; } = "{}"; + [BindNever] + public bool IsPresent { get; set; } + public bool HasContent => !string.IsNullOrWhiteSpace(Title) || !string.IsNullOrWhiteSpace(Body) || HasActions(); + + private bool HasActions() + { + try + { + var actions = DAL.Abstract.Templates.PushTemplateActionValidation.Parse(ActionsJson); + return actions.DefaultAction is not null || actions.Buttons.Count > 0; + } + catch (DAL.Abstract.Templates.EmailTemplateWriteException) { return true; } + } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (!HasContent) yield break; + if (string.IsNullOrWhiteSpace(Title)) + yield return new ValidationResult("Title is required for a populated language.", [nameof(Title)]); + if (string.IsNullOrWhiteSpace(Body)) + yield return new ValidationResult("Body is required for a populated language.", [nameof(Body)]); + } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/ServiceTemplatesViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/ServiceTemplatesViewModel.cs index 4443b99..0904cb3 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/ServiceTemplatesViewModel.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/ServiceTemplatesViewModel.cs @@ -6,5 +6,6 @@ public sealed class ServiceTemplatesViewModel { public required NotificationServiceInfo Service { get; init; } public string? KeyFilter { get; init; } + public IReadOnlyList PushDefinitions { get; set; } = []; public IReadOnlyList Definitions { get; set; } = []; } diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/TemplateChannelNavigationViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/TemplateChannelNavigationViewModel.cs new file mode 100644 index 0000000..60487da --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/TemplateChannelNavigationViewModel.cs @@ -0,0 +1,10 @@ +namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; + +public sealed class TemplateChannelNavigationViewModel +{ + public Guid ServiceId { get; init; } + public string ServiceName { get; init; } = ""; + public string Key { get; init; } = ""; + public string? KeyFilter { get; init; } + public bool IsPush { get; init; } +} diff --git a/HrynCo.NotificationService.Web/Views/AdminPushChannels/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminPushChannels/Edit.cshtml index fbbbf8a..48fce4e 100644 --- a/HrynCo.NotificationService.Web/Views/AdminPushChannels/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminPushChannels/Edit.cshtml @@ -73,10 +73,26 @@
- +
+
-
One contract for every language: [{"name":"UserName","required":true}]
+
Email variables, shared across its languages: [{"name":"UserName","required":true}]

Every configured service language has a tab. Present and Missing show saved versions. Leave all three content fields blank for an unused language; clear them to remove an existing version. Shared variables and the template remain.

@if (Model.Languages.Count == 0) {

No service languages configured. You can save the key and shared variables now, then add language codes in Edit Service.

} @@ -102,6 +103,7 @@ Cancel } @section Scripts { + } diff --git a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml index 200c7b3..1a8b0d3 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml @@ -25,7 +25,7 @@ } @@ -40,12 +40,13 @@ - Languages + Email + Push Action -@if (Model.Definitions.Count == 0) +@if (Model.Definitions.Count == 0 && Model.PushDefinitions.Count == 0) { -
+
@if (!ViewData.ModelState.IsValid) {

Templates are unavailable. Review the error and try again.

@@ -56,18 +57,21 @@ } else { -

No email templates for this service yet.

Create First Template +

No templates for this service yet.

Create First Template }
} -@foreach (var template in Model.Definitions) +@foreach (var templateKey in Model.Definitions.Select(d => d.Key).Concat(Model.PushDefinitions.Select(d => d.Key)).Distinct(StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal)) { - var editUrl = Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = template.Id, keyFilter }); - var presentCodes = template.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal); + var email = Model.Definitions.SingleOrDefault(d => d.Key == templateKey); + var push = Model.PushDefinitions.SingleOrDefault(d => d.Key == templateKey); + var editUrl = email is null ? Url.Action("Edit", "AdminPushTemplates", new { serviceId, key = templateKey, keyFilter }) : Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = email.Id, keyFilter }); + var presentCodes = email?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal) ?? []; - @template.Key + @templateKey - @if (Model.Service.Languages.Count == 0) + @if (email is null) { Not configured } + else if (Model.Service.Languages.Count == 0) { No configured languages } @@ -84,7 +88,22 @@ } - + + @if (push is null) { Not configured } + else if (Model.Service.Languages.Count == 0) { No configured languages } + else { + var pushCodes = push.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal); +
    + @foreach (var language in Model.Service.Languages) { + var present = pushCodes.Contains(language.Code); +
  • @language.Code:@(present ? "Present" : "Missing")
  • + } +
+ } + + }
diff --git a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml index 3158c1b..b763f30 100644 --- a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml +++ b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml @@ -14,7 +14,7 @@ - +