From 81654c0d33a4f40dc5a9d658ee97c5ecef9df47a Mon Sep 17 00:00:00 2001 From: Anatolii Hrynchuk Date: Sat, 17 Oct 2026 22:02:43 +0300 Subject: [PATCH] feat(templates): add service language template management Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage. Ref: IT-1601 --- Directory.Packages.props | 4 +- .../IEmailTemplateDefinitionRepository.cs | 10 + .../INotificationServiceRepository.cs | 11 + .../Templates/EmailTemplateDefinition.cs | 13 + .../Templates/EmailTemplateValidation.cs | 76 ++++ ...teVariable.cs => EmailTemplateVariable.cs} | 0 .../Templates/EmailTemplateWriteException.cs | 6 + .../Templates/NotificationServiceInfo.cs | 9 + .../Templates/ServiceLanguageInfo.cs | 8 + ...ilTemplateDefinitionEntityConfiguration.cs | 29 ++ .../EmailTemplateEntityConfiguration.cs | 54 +-- .../ServiceEntityConfiguration.cs | 20 + .../ServiceLanguageEntityConfiguration.cs | 22 + .../Entities/EmailTemplateDefinitionEntity.cs | 13 + .../Entities/EmailTemplateEntity.cs | 15 +- .../Entities/EmailTemplateVariableData.cs | 7 + .../Entities/ServiceEntity.cs | 10 + .../Entities/ServiceLanguageEntity.cs | 10 + ...410_EmailTemplateLocalizations.Designer.cs | 376 ++++++++++++++++++ ...261017072410_EmailTemplateLocalizations.cs | 284 +++++++++++++ .../NotificationDbContextModelSnapshot.cs | 213 ++++++++-- .../NotificationDbContext.cs | 3 + .../Repositories/EmailTemplateRepository.cs | 274 +++++++++---- .../NotificationServiceRepository.cs | 188 +++++++++ .../ServiceCollectionExtensions.cs | 3 + .../TemplateMutationRunner.cs | 19 + .../DevelopmentDataSeeder.cs | 16 + .../DevelopmentDataSeederTests.cs | 2 + .../EmailTemplateServiceTests.cs | 95 ++++- .../Create/CreateEmailTemplateHandler.cs | 9 +- .../Delete/DeleteEmailTemplateCommand.cs | 3 +- .../Delete/DeleteEmailTemplateHandler.cs | 12 +- .../SaveEmailTemplateDefinitionCommand.cs | 8 + .../SaveEmailTemplateDefinitionHandler.cs | 26 ++ .../Update/UpdateEmailTemplateHandler.cs | 10 +- .../AdminTemplatesIndexViewTests.cs | 30 +- .../EditorLayoutTests.cs | 68 ++++ .../EmailTemplateApiTests.cs | 212 ++++++++++ .../EmailTemplateMigrationTests.cs | 106 +++++ ...ailingEmailTemplateDefinitionRepository.cs | 16 + ...icationService.Web.IntegrationTests.csproj | 8 +- .../InterimEmailTemplateFixture.cs | 41 ++ .../InterimEmailTemplateMigrationTests.cs | 131 ++++++ .../ServiceLanguageTests.cs | 256 ++++++++++++ .../ServiceTemplateNavigationTests.cs | 119 ++++++ .../TemplateCatalogPresentationTests.cs | 103 +++++ .../TemplateDatabaseFixture.cs | 71 ++++ .../TemplateEditorTestFields.cs | 31 ++ .../TemplateWebApplicationFactory.cs | 13 + .../WorkerTemplateIntegrationTests.cs | 93 +++++ .../Admin/AdminServicesController.cs | 125 ++++++ .../Admin/AdminTemplatesController.cs | 287 ++++++------- .../Admin/LegacyAdminTemplatesController.cs | 38 ++ .../ViewModels/EmailTemplateEditViewModel.cs | 26 +- .../EmailTemplateLanguageEditViewModel.cs | 28 ++ .../NotificationServiceEditViewModel.cs | 14 + .../ServiceLanguageDeleteViewModel.cs | 12 + .../ViewModels/ServiceTemplatesViewModel.cs | 10 + HrynCo.NotificationService.Web/Program.cs | 5 +- .../Views/AdminServices/DeleteLanguage.cshtml | 13 + .../Views/AdminServices/Edit.cshtml | 48 +++ .../Views/AdminServices/Index.cshtml | 14 + .../Views/AdminTemplates/Edit.cshtml | 366 ++++------------- .../Views/AdminTemplates/Index.cshtml | 227 +++-------- .../Views/Shared/_EditorLayout.cshtml | 3 +- .../Views/Shared/_Layout.cshtml | 9 +- .../wwwroot/css/admin.css | 208 +++++++++- .../wwwroot/js/email-template-editor.js | 130 ++++++ .../wwwroot/js/service-editor.js | 36 ++ .../EmailProcessing/EmailTemplateService.cs | 12 +- README.md | 14 +- docs/email-template-localizations.md | 96 +++++ docs/itemtracker-outbox-email-consumer.md | 4 +- 73 files changed, 4061 insertions(+), 820 deletions(-) create mode 100644 HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinition.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateValidation.cs rename HrynCo.NotificationService.DAL.Abstract/Templates/{TemplateVariable.cs => EmailTemplateVariable.cs} (100%) create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateWriteException.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/NotificationServiceInfo.cs create mode 100644 HrynCo.NotificationService.DAL.Abstract/Templates/ServiceLanguageInfo.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Configurations/ServiceEntityConfiguration.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Configurations/ServiceLanguageEntityConfiguration.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateDefinitionEntity.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateVariableData.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/ServiceEntity.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Entities/ServiceLanguageEntity.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.Designer.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.cs create mode 100644 HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs create mode 100644 HrynCo.NotificationService.DAL.EF/TemplateMutationRunner.cs create mode 100644 HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionCommand.cs create mode 100644 HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionHandler.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateApiTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/EmailTemplateMigrationTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/FailingEmailTemplateDefinitionRepository.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/InterimEmailTemplateFixture.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/InterimEmailTemplateMigrationTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/ServiceLanguageTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/ServiceTemplateNavigationTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/TemplateCatalogPresentationTests.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/TemplateDatabaseFixture.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/TemplateEditorTestFields.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/TemplateWebApplicationFactory.cs create mode 100644 HrynCo.NotificationService.Web.IntegrationTests/WorkerTemplateIntegrationTests.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/AdminServicesController.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/LegacyAdminTemplatesController.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailTemplateLanguageEditViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/NotificationServiceEditViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/ServiceLanguageDeleteViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/ServiceTemplatesViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Views/AdminServices/DeleteLanguage.cshtml create mode 100644 HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml create mode 100644 HrynCo.NotificationService.Web/Views/AdminServices/Index.cshtml create mode 100644 HrynCo.NotificationService.Web/wwwroot/js/email-template-editor.js create mode 100644 HrynCo.NotificationService.Web/wwwroot/js/service-editor.js create mode 100644 docs/email-template-localizations.md diff --git a/Directory.Packages.props b/Directory.Packages.props index d1fc679..618da09 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -36,7 +36,9 @@ + + - + diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs new file mode 100644 index 0000000..6c0a97d --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs @@ -0,0 +1,10 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.DAL.Abstract.Repositories; + +public interface IEmailTemplateDefinitionRepository +{ + Task> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default); + Task GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default); + Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default); +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs new file mode 100644 index 0000000..445fdb8 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs @@ -0,0 +1,11 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.DAL.Abstract.Repositories; + +public interface INotificationServiceRepository +{ + Task> GetAllAsync(CancellationToken ct = default); + Task GetAsync(Guid id, CancellationToken ct = default); + Task SaveAsync(NotificationServiceInfo service, bool isNew, CancellationToken ct = default); + Task RemoveLanguageAsync(Guid serviceId, Guid languageId, int expectedVersionCount, Guid expectedRevision, CancellationToken ct = default); +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinition.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinition.cs new file mode 100644 index 0000000..359e775 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinition.cs @@ -0,0 +1,13 @@ +using HrynCo.DAL.Abstract.Entities; + +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class EmailTemplateDefinition : Entity +{ + public Guid ServiceId { get; set; } + public required string ServiceName { 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/EmailTemplateValidation.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateValidation.cs new file mode 100644 index 0000000..0daa9dd --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateValidation.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public static class EmailTemplateValidation +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = false, + UnmappedMemberHandling = System.Text.Json.Serialization.JsonUnmappedMemberHandling.Disallow + }; + + public static IReadOnlyList ParseVariables(string json) + { + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind == JsonValueKind.Null) + throw new EmailTemplateWriteException("Variables must be a JSON array.", false); + if (document.RootElement.ValueKind != JsonValueKind.Array) + throw new JsonException("Variables must be an array."); + foreach (var item in document.RootElement.EnumerateArray()) + ValidateVariableShape(item); + var variables = JsonSerializer.Deserialize>(json, JsonOptions); + ValidateVariables(variables); + return variables!; + } + + private static void ValidateVariableShape(JsonElement item) + { + if (item.ValueKind != JsonValueKind.Object) + throw new JsonException("Each variable must be an object."); + if (!item.TryGetProperty("name", out var name) || name.ValueKind != JsonValueKind.String) + throw new JsonException("Every variable needs a name string."); + if (!item.TryGetProperty("required", out var required) || required.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + throw new JsonException("Every variable needs a required boolean."); + } + + public static void ValidateVariables(IReadOnlyList? variables) + { + if (variables is null) + throw new EmailTemplateWriteException("Variables must be a JSON array.", false); + if (variables.Any(v => v is null || string.IsNullOrWhiteSpace(v.Name))) + throw new EmailTemplateWriteException("Every variable needs a non-empty name.", false); + if (variables.Select(v => v.Name).Distinct(StringComparer.Ordinal).Count() != variables.Count) + throw new EmailTemplateWriteException("Variable names must be unique.", false); + } + + public static bool ContractsEqual(IReadOnlyList left, IReadOnlyList right) => + left.OrderBy(v => v.Name, StringComparer.Ordinal).SequenceEqual(right.OrderBy(v => v.Name, StringComparer.Ordinal)); + + public static void ValidateIdentity(string serviceName, string key) + { + if (string.IsNullOrWhiteSpace(serviceName) || serviceName.Length > 100) + throw new EmailTemplateWriteException("Service name is required and must be at most 100 characters.", false); + if (string.IsNullOrWhiteSpace(key) || key.Length > 100) + throw new EmailTemplateWriteException("Key is required and must be at most 100 characters.", false); + } + + public static void ValidateLanguage(EmailTemplate template) + { + if (string.IsNullOrWhiteSpace(template.LanguageCode) || template.LanguageCode.Length > 10) + throw new EmailTemplateWriteException("Language code is required and must be at most 10 characters.", false); + if (string.IsNullOrWhiteSpace(template.Subject) || string.IsNullOrWhiteSpace(template.HtmlBody) || string.IsNullOrWhiteSpace(template.TextBody)) + throw new EmailTemplateWriteException("Every language needs a subject, HTML body, and text body.", false); + } + + public static void ValidateDefinition(EmailTemplateDefinition definition) + { + ValidateIdentity(definition.ServiceName, definition.Key); + ValidateVariables(definition.Variables); + foreach (var language in definition.Languages) + ValidateLanguage(language); + var codes = definition.Languages.Select(t => t.LanguageCode); + if (codes.Distinct(StringComparer.Ordinal).Count() != definition.Languages.Count) + throw new EmailTemplateWriteException("Language codes must be unique within a template.", false); + } +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/TemplateVariable.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateVariable.cs similarity index 100% rename from HrynCo.NotificationService.DAL.Abstract/Templates/TemplateVariable.cs rename to HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateVariable.cs diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateWriteException.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateWriteException.cs new file mode 100644 index 0000000..735e6c4 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateWriteException.cs @@ -0,0 +1,6 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class EmailTemplateWriteException(string message, bool isConflict = true) : Exception(message) +{ + public bool IsConflict { get; } = isConflict; +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/NotificationServiceInfo.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/NotificationServiceInfo.cs new file mode 100644 index 0000000..447c435 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/NotificationServiceInfo.cs @@ -0,0 +1,9 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class NotificationServiceInfo +{ + public Guid Id { get; set; } + public required string Name { get; set; } + public Guid Revision { get; set; } + public IReadOnlyList Languages { get; set; } = []; +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Templates/ServiceLanguageInfo.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/ServiceLanguageInfo.cs new file mode 100644 index 0000000..81b5bb1 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/ServiceLanguageInfo.cs @@ -0,0 +1,8 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class ServiceLanguageInfo +{ + public Guid Id { get; set; } + public required string Code { get; set; } + public int VersionCount { get; set; } +} diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs new file mode 100644 index 0000000..fd5853c --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateDefinitionEntityConfiguration.cs @@ -0,0 +1,29 @@ +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace HrynCo.NotificationService.DAL.EF.Configurations; + +internal sealed class EmailTemplateDefinitionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("email_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.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict); + 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/EmailTemplateEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateEntityConfiguration.cs index 7522d01..d63b482 100644 --- a/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateEntityConfiguration.cs +++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailTemplateEntityConfiguration.cs @@ -4,53 +4,27 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace HrynCo.NotificationService.DAL.EF.Configurations; -internal class EmailTemplateEntityConfiguration : IEntityTypeConfiguration +internal sealed class EmailTemplateEntityConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) { builder.ToTable("email_templates"); - builder.HasKey(x => x.Id); builder.Property(x => x.Id).HasColumnName("id"); - - builder.Property(x => x.ServiceName) - .HasColumnName("service_name") - .IsRequired() - .HasMaxLength(100); - - builder.Property(x => x.Key) - .HasColumnName("key") - .IsRequired() - .HasMaxLength(100); - - builder.Property(x => x.LanguageCode) - .HasColumnName("language_code") - .IsRequired() - .HasMaxLength(10); - - builder.HasIndex(x => new { x.ServiceName, x.Key, x.LanguageCode }) - .IsUnique(); - - builder.Property(x => x.Subject) - .HasColumnName("subject") - .IsRequired(); - - builder.Property(x => x.HtmlBody) - .HasColumnName("html_body") - .IsRequired(); - - builder.Property(x => x.TextBody) - .HasColumnName("text_body") - .IsRequired(); - + 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.Subject).HasColumnName("subject").IsRequired(); + builder.Property(x => x.HtmlBody).HasColumnName("html_body").IsRequired(); + builder.Property(x => x.TextBody).HasColumnName("text_body").IsRequired(); 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/ServiceEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/ServiceEntityConfiguration.cs new file mode 100644 index 0000000..0c80f3a --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/ServiceEntityConfiguration.cs @@ -0,0 +1,20 @@ +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace HrynCo.NotificationService.DAL.EF.Configurations; + +internal sealed class ServiceEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("services"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.Name).HasColumnName("name").HasMaxLength(100).IsRequired(); + builder.HasIndex(x => x.Name).IsUnique(); + builder.Property(x => x.Revision).HasColumnName("revision").IsConcurrencyToken(); + builder.Property(x => x.Created).HasColumnName("created"); + builder.Property(x => x.Updated).HasColumnName("updated"); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/ServiceLanguageEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/ServiceLanguageEntityConfiguration.cs new file mode 100644 index 0000000..1c0d4db --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/ServiceLanguageEntityConfiguration.cs @@ -0,0 +1,22 @@ +using HrynCo.NotificationService.DAL.EF.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace HrynCo.NotificationService.DAL.EF.Configurations; + +internal sealed class ServiceLanguageEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("service_languages"); + builder.HasKey(x => x.Id); + builder.Property(x => x.Id).HasColumnName("id"); + builder.Property(x => x.ServiceId).HasColumnName("service_id"); + builder.Property(x => x.Code).HasColumnName("code").HasMaxLength(10).IsRequired(); + builder.HasIndex(x => new { x.ServiceId, x.Code }).IsUnique(); + builder.HasAlternateKey(x => new { x.Id, x.ServiceId }); + builder.HasOne(x => x.Service).WithMany(x => x.Languages).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 new file mode 100644 index 0000000..9dcac36 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateDefinitionEntity.cs @@ -0,0 +1,13 @@ +using HrynCo.DAL.Abstract.Entities; + +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class EmailTemplateDefinitionEntity : Entity +{ + public Guid ServiceId { get; set; } + public ServiceEntity Service { get; set; } = null!; + public required string Key { get; set; } + 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/EmailTemplateEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs index a68f0c5..bc0d639 100644 --- a/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateEntity.cs @@ -4,17 +4,12 @@ namespace HrynCo.NotificationService.DAL.EF.Entities; internal class EmailTemplateEntity : Entity { - public required string ServiceName { get; set; } - public required string Key { get; set; } - public required string LanguageCode { get; set; } + public Guid DefinitionId { get; set; } + public EmailTemplateDefinitionEntity Definition { get; set; } = null!; + public Guid ServiceId { get; set; } + public Guid ServiceLanguageId { get; set; } + public ServiceLanguageEntity ServiceLanguage { get; set; } = null!; public required string Subject { get; set; } public required string HtmlBody { get; set; } public required string TextBody { get; set; } - public List Variables { get; set; } = []; -} - -internal class EmailTemplateVariableData -{ - public required string Name { get; set; } - public bool Required { get; set; } } diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateVariableData.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateVariableData.cs new file mode 100644 index 0000000..0c57ba3 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailTemplateVariableData.cs @@ -0,0 +1,7 @@ +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class EmailTemplateVariableData +{ + public required string Name { get; set; } + public bool Required { get; set; } +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/ServiceEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/ServiceEntity.cs new file mode 100644 index 0000000..8c7baeb --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/ServiceEntity.cs @@ -0,0 +1,10 @@ +using HrynCo.DAL.Abstract.Entities; + +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class ServiceEntity : Entity +{ + public required string Name { get; set; } + public Guid Revision { get; set; } = Guid.NewGuid(); + public List Languages { get; set; } = []; +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/ServiceLanguageEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/ServiceLanguageEntity.cs new file mode 100644 index 0000000..86190db --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/ServiceLanguageEntity.cs @@ -0,0 +1,10 @@ +using HrynCo.DAL.Abstract.Entities; + +namespace HrynCo.NotificationService.DAL.EF.Entities; + +internal sealed class ServiceLanguageEntity : Entity +{ + public Guid ServiceId { get; set; } + public ServiceEntity Service { get; set; } = null!; + public required string Code { get; set; } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.Designer.cs new file mode 100644 index 0000000..f1712a5 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.Designer.cs @@ -0,0 +1,376 @@ +// +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("20261017072410_EmailTemplateLocalizations")] + partial class EmailTemplateLocalizations + { + /// + 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("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + 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("ServiceName", "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.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.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.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("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.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.EmailChannelEntity", b => + { + b.Navigation("UsageRecords"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => + { + b.Navigation("Languages"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.cs new file mode 100644 index 0000000..079f0b8 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261017072410_EmailTemplateLocalizations.cs @@ -0,0 +1,284 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations; + +public partial class EmailTemplateLocalizations : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(SourceLayoutGuard()); + migrationBuilder.Sql("DO $upgrade$ BEGIN IF (SELECT layout FROM it1601_migration_origin) = 'flat' THEN\n" + + FlatUpgrade() + "\nELSE\n" + InterimUpgrade() + "\nEND IF; END $upgrade$;"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + LOCK TABLE email_templates IN ACCESS EXCLUSIVE MODE; + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261017070315_EmailTemplateLocalizations') THEN + RAISE EXCEPTION 'Cannot downgrade an interim-origin database to the flat legacy model. Restore a verified backup instead.'; + END IF; + IF EXISTS (SELECT 1 FROM email_template_definitions d WHERE NOT EXISTS (SELECT 1 FROM email_templates t WHERE t.definition_id = d.id)) + OR EXISTS (SELECT 1 FROM service_languages l WHERE NOT EXISTS (SELECT 1 FROM email_templates t WHERE t.service_language_id = l.id)) + OR EXISTS (SELECT 1 FROM services s WHERE NOT EXISTS (SELECT 1 FROM email_templates t WHERE t.service_id = s.id)) THEN + RAISE EXCEPTION 'Cannot downgrade catalog-only data or empty definitions to the legacy model. Restore a verified backup instead.'; + END IF; + END $$; + ALTER TABLE email_templates ADD COLUMN service_name varchar(100), ADD COLUMN key varchar(100), + ADD COLUMN language_code varchar(10), ADD COLUMN variables jsonb; + UPDATE email_templates t SET service_name = s.name, key = d.key, variables = d.variables, language_code = l.code + FROM email_template_definitions d JOIN services s ON s.id = d.service_id + JOIN service_languages l ON l.service_id = s.id + WHERE d.id = t.definition_id AND l.id = t.service_language_id; + ALTER TABLE email_templates ALTER COLUMN service_name SET NOT NULL, ALTER COLUMN key SET NOT NULL, ALTER COLUMN language_code SET NOT NULL; + ALTER TABLE email_templates DROP COLUMN definition_id, DROP COLUMN service_id, DROP COLUMN service_language_id; + DROP TABLE email_template_definitions; + DROP TABLE service_languages; + DROP TABLE services; + CREATE UNIQUE INDEX "IX_email_templates_service_name_key_language_code" ON email_templates(service_name, key, language_code); + """); + } + + private static string FlatUpgrade() => """ + DO $audit$ + DECLARE row_record record; + BEGIN + FOR row_record IN SELECT id, service_name, key, language_code, variables FROM email_templates LOOP + IF btrim(row_record.service_name) = '' OR btrim(row_record.key) = '' + OR btrim(row_record.language_code) = '' OR row_record.variables IS NULL + OR jsonb_typeof(row_record.variables) <> 'array' THEN + RAISE EXCEPTION 'Invalid template identity or variables array: service=%, key=%, language=%, id=%', + row_record.service_name, row_record.key, row_record.language_code, row_record.id; + END IF; + IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v + WHERE jsonb_typeof(v) <> 'object' + OR jsonb_typeof(v->'name') IS DISTINCT FROM 'string' + OR btrim(v->>'name') = '' + OR jsonb_typeof(v->'required') IS DISTINCT FROM 'boolean' + OR (v - 'name' - 'required') <> '{}'::jsonb) THEN + RAISE EXCEPTION 'Invalid variables shape: service=%, key=%, language=%, id=%', + row_record.service_name, row_record.key, row_record.language_code, row_record.id; + END IF; + IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v GROUP BY v->>'name' HAVING count(*) > 1) THEN + RAISE EXCEPTION 'Duplicate variable names: service=%, key=%, language=%, id=%', + row_record.service_name, row_record.key, row_record.language_code, row_record.id; + END IF; + END LOOP; + END $audit$; + CREATE TEMP TABLE template_contract_audit ON COMMIT DROP AS + SELECT t.*, COALESCE((SELECT jsonb_agg(v ORDER BY v->>'name') + FROM jsonb_array_elements(t.variables) v), '[]'::jsonb) AS contract + FROM email_templates t; + DO $conflicts$ + DECLARE conflict_record record; + BEGIN + SELECT service_name, key, string_agg(language_code || ':' || id::text, ', ' ORDER BY language_code) AS records + INTO conflict_record FROM template_contract_audit + GROUP BY service_name, key HAVING count(DISTINCT contract) > 1 LIMIT 1; + IF FOUND THEN + RAISE EXCEPTION 'Conflicting shared variables: service=%, key=%, records=%', + conflict_record.service_name, conflict_record.key, conflict_record.records; + END IF; + END $conflicts$; + CREATE TABLE services ( + id uuid CONSTRAINT "PK_services" PRIMARY KEY, name varchar(100) NOT NULL, revision uuid NOT NULL, + created timestamptz NOT NULL, updated timestamptz NULL); + CREATE UNIQUE INDEX "IX_services_name" ON services(name); + CREATE TABLE service_languages ( + id uuid CONSTRAINT "PK_service_languages" PRIMARY KEY, service_id uuid NOT NULL, + code varchar(10) NOT NULL, created timestamptz NOT NULL, updated timestamptz NULL, + CONSTRAINT "AK_service_languages_id_service_id" UNIQUE(id, service_id), + CONSTRAINT "FK_service_languages_services_service_id" FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE RESTRICT); + CREATE UNIQUE INDEX "IX_service_languages_service_id_code" ON service_languages(service_id, code); + CREATE TABLE email_template_definitions ( + id uuid CONSTRAINT "PK_email_template_definitions" PRIMARY KEY, service_id uuid NOT NULL, + key varchar(100) NOT NULL, revision uuid NOT NULL, variables jsonb NOT NULL, + created timestamptz NOT NULL, updated timestamptz NULL, + CONSTRAINT "AK_email_template_definitions_id_service_id" UNIQUE(id, service_id), + CONSTRAINT "FK_email_template_definitions_services_service_id" FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE RESTRICT); + CREATE UNIQUE INDEX "IX_email_template_definitions_service_id_key" ON email_template_definitions(service_id, key); + INSERT INTO services(id, name, revision, created) + SELECT gen_random_uuid(), service_name, gen_random_uuid(), min(created) FROM email_templates GROUP BY service_name; + INSERT INTO service_languages(id, service_id, code, created) + SELECT gen_random_uuid(), s.id, t.language_code, min(t.created) + FROM email_templates t JOIN services s ON s.name = t.service_name GROUP BY s.id, t.language_code; + INSERT INTO email_template_definitions(id, service_id, key, revision, variables, created, updated) + SELECT DISTINCT ON (t.service_name, t.key) t.id, s.id, t.key, gen_random_uuid(), t.contract, + min(t.created) OVER (PARTITION BY t.service_name, t.key), + max(t.updated) OVER (PARTITION BY t.service_name, t.key) + FROM template_contract_audit t JOIN services s ON s.name = t.service_name + ORDER BY t.service_name, t.key, t.id; + ALTER TABLE email_templates ADD COLUMN definition_id uuid, ADD COLUMN service_id uuid, ADD COLUMN service_language_id uuid; + UPDATE email_templates t SET definition_id = d.id, service_id = s.id, service_language_id = l.id + FROM email_template_definitions d JOIN services s ON s.id = d.service_id + JOIN service_languages l ON l.service_id = s.id + WHERE t.service_name = s.name AND t.key = d.key AND t.language_code = l.code; + ALTER TABLE email_templates ALTER COLUMN definition_id SET NOT NULL, + ALTER COLUMN service_id SET NOT NULL, ALTER COLUMN service_language_id SET NOT NULL; + ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_email_template_definitions_definition_id_se~" + FOREIGN KEY (definition_id, service_id) REFERENCES email_template_definitions(id, service_id) ON DELETE CASCADE; + ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_service_languages_service_language_id_servi~" + FOREIGN KEY (service_language_id, service_id) REFERENCES service_languages(id, service_id) ON DELETE RESTRICT; + DROP INDEX "IX_email_templates_service_name_key_language_code"; + ALTER TABLE email_templates DROP COLUMN service_name, DROP COLUMN key, DROP COLUMN variables, DROP COLUMN language_code; + CREATE UNIQUE INDEX "IX_email_templates_definition_id_service_language_id" ON email_templates(definition_id, service_language_id); + CREATE INDEX "IX_email_templates_definition_id_service_id" ON email_templates(definition_id, service_id); + CREATE INDEX "IX_email_templates_service_language_id_service_id" ON email_templates(service_language_id, service_id); + """; + + private static string SourceLayoutGuard() => """ + SET LOCAL search_path = public; + DO $layout$ + DECLARE + template_columns text[]; + service_columns text[]; + definition_columns text[]; + source_constraints text[]; + source_indexes text[]; + known_interim_history boolean; + source_layout text; + BEGIN + SELECT array_agg(column_name || ':' || udt_name || ':' || coalesce(character_maximum_length::text, '') || ':' || is_nullable ORDER BY column_name) + INTO template_columns FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'email_templates'; + SELECT array_agg(column_name || ':' || udt_name || ':' || coalesce(character_maximum_length::text, '') || ':' || is_nullable ORDER BY column_name) + INTO service_columns FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'services'; + SELECT array_agg(column_name || ':' || udt_name || ':' || coalesce(character_maximum_length::text, '') || ':' || is_nullable ORDER BY column_name) + INTO definition_columns FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'email_template_definitions'; + SELECT EXISTS (SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20261017070315_EmailTemplateLocalizations') INTO known_interim_history; + SELECT array_agg(c.relname || ':' || p.conname || ':' || pg_get_constraintdef(p.oid) ORDER BY c.relname, p.conname) + INTO source_constraints FROM pg_constraint p JOIN pg_class c ON c.oid = p.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'public' + AND c.relname IN ('services', 'email_template_definitions', 'email_templates'); + SELECT array_agg(indexdef ORDER BY indexname) INTO source_indexes FROM pg_indexes + WHERE schemaname = 'public' AND tablename IN ('services', 'email_template_definitions', 'email_templates'); + IF to_regclass('public.service_languages') IS NOT NULL THEN + RAISE EXCEPTION 'Unsupported email-template source layout: service language catalog already exists without the current migration history.'; + END IF; + IF template_columns = ARRAY['created:timestamptz::NO', + 'html_body:text::NO', + 'id:uuid::NO', + 'key:varchar:100:NO', + 'language_code:varchar:10:NO', + 'service_name:varchar:100:NO', + 'subject:text::NO', + 'text_body:text::NO', + 'updated:timestamptz::YES', + 'variables:jsonb::YES'] + AND service_columns IS NULL AND definition_columns IS NULL AND NOT known_interim_history + AND source_constraints = ARRAY['email_templates:PK_email_templates:PRIMARY KEY (id)'] + AND source_indexes = ARRAY[ + 'CREATE UNIQUE INDEX "IX_email_templates_service_name_key_language_code" ON public.email_templates USING btree (service_name, key, language_code)', + 'CREATE UNIQUE INDEX "PK_email_templates" ON public.email_templates USING btree (id)'] THEN + source_layout := 'flat'; + ELSIF template_columns = ARRAY['created:timestamptz::NO', + 'definition_id:uuid::NO', + 'html_body:text::NO', + 'id:uuid::NO', + 'language_code:varchar:10:NO', + 'subject:text::NO', + 'text_body:text::NO', + 'updated:timestamptz::YES'] + AND service_columns = ARRAY['created:timestamptz::NO', + 'id:uuid::NO', + 'name:varchar:100:NO', + 'updated:timestamptz::YES'] + AND definition_columns = ARRAY['created:timestamptz::NO', + 'id:uuid::NO', + 'key:varchar:100:NO', + 'revision:uuid::NO', + 'service_id:uuid::NO', + 'updated:timestamptz::YES', + 'variables:jsonb::NO'] + AND known_interim_history + AND source_constraints = ARRAY[ + 'email_template_definitions:email_template_definitions_pkey:PRIMARY KEY (id)', + 'email_template_definitions:email_template_definitions_service_id_fkey:FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE RESTRICT', + 'email_templates:FK_email_templates_email_template_definitions_definition_id:FOREIGN KEY (definition_id) REFERENCES email_template_definitions(id) ON DELETE CASCADE', + 'email_templates:PK_email_templates:PRIMARY KEY (id)', + 'services:services_pkey:PRIMARY KEY (id)'] + AND source_indexes = ARRAY[ + 'CREATE UNIQUE INDEX "IX_email_template_definitions_service_id_key" ON public.email_template_definitions USING btree (service_id, key)', + 'CREATE UNIQUE INDEX "IX_email_templates_definition_id_language_code" ON public.email_templates USING btree (definition_id, language_code)', + 'CREATE UNIQUE INDEX "IX_services_name" ON public.services USING btree (name)', + 'CREATE UNIQUE INDEX "PK_email_templates" ON public.email_templates USING btree (id)', + 'CREATE UNIQUE INDEX email_template_definitions_pkey ON public.email_template_definitions USING btree (id)', + 'CREATE UNIQUE INDEX services_pkey ON public.services USING btree (id)'] THEN + source_layout := 'interim'; + ELSE + RAISE EXCEPTION 'Unsupported email-template source layout or history. Expected the flat legacy schema or the known 20261017070315 interim schema; no changes were made.'; + END IF; + LOCK TABLE email_templates IN ACCESS EXCLUSIVE MODE; + IF source_layout = 'interim' THEN + LOCK TABLE services, email_template_definitions IN ACCESS EXCLUSIVE MODE; + END IF; + CREATE TEMP TABLE it1601_migration_origin (layout text NOT NULL) ON COMMIT DROP; + INSERT INTO it1601_migration_origin VALUES (source_layout); + END $layout$; + """; + + private static string InterimUpgrade() => """ + DO $interim_audit$ + DECLARE row_record record; + BEGIN + IF EXISTS (SELECT 1 FROM services WHERE btrim(name) = '') + OR EXISTS (SELECT 1 FROM email_template_definitions WHERE btrim(key) = '') + OR EXISTS (SELECT 1 FROM email_templates WHERE btrim(language_code) = '') THEN + RAISE EXCEPTION 'Invalid blank identity in interim email-template data; no changes were made.'; + END IF; + IF EXISTS (SELECT 1 FROM email_template_definitions d LEFT JOIN services s ON s.id = d.service_id WHERE s.id IS NULL) + OR EXISTS (SELECT 1 FROM email_templates t LEFT JOIN email_template_definitions d ON d.id = t.definition_id WHERE d.id IS NULL) THEN + RAISE EXCEPTION 'Invalid orphaned reference in interim email-template data; no changes were made.'; + END IF; + FOR row_record IN SELECT d.id, s.name AS service_name, d.key, d.variables FROM email_template_definitions d JOIN services s ON s.id = d.service_id LOOP + IF row_record.variables IS NULL OR jsonb_typeof(row_record.variables) <> 'array' THEN + RAISE EXCEPTION 'Invalid interim shared variables array: service=%, key=%, definition=%', row_record.service_name, row_record.key, row_record.id; + END IF; + IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v + WHERE jsonb_typeof(v) <> 'object' + OR jsonb_typeof(v->'name') IS DISTINCT FROM 'string' + OR btrim(v->>'name') = '' + OR jsonb_typeof(v->'required') IS DISTINCT FROM 'boolean' + OR (v - 'name' - 'required') <> '{}'::jsonb) THEN + RAISE EXCEPTION 'Invalid interim shared variables shape: service=%, key=%, definition=%', row_record.service_name, row_record.key, row_record.id; + END IF; + IF EXISTS (SELECT 1 FROM jsonb_array_elements(row_record.variables) v GROUP BY v->>'name' HAVING count(*) > 1) THEN + RAISE EXCEPTION 'Duplicate interim shared variable names: service=%, key=%, definition=%', row_record.service_name, row_record.key, row_record.id; + END IF; + END LOOP; + END $interim_audit$; + ALTER TABLE services ADD COLUMN revision uuid; + UPDATE services SET revision = gen_random_uuid(); + ALTER TABLE services ALTER COLUMN revision SET NOT NULL; + ALTER TABLE services RENAME CONSTRAINT services_pkey TO "PK_services"; + ALTER TABLE email_template_definitions RENAME CONSTRAINT email_template_definitions_pkey TO "PK_email_template_definitions"; + ALTER TABLE email_template_definitions RENAME CONSTRAINT email_template_definitions_service_id_fkey TO "FK_email_template_definitions_services_service_id"; + ALTER TABLE email_template_definitions ADD CONSTRAINT "AK_email_template_definitions_id_service_id" UNIQUE(id, service_id); + CREATE TABLE service_languages ( + id uuid CONSTRAINT "PK_service_languages" PRIMARY KEY, service_id uuid NOT NULL, + code varchar(10) NOT NULL, created timestamptz NOT NULL, updated timestamptz NULL, + CONSTRAINT "AK_service_languages_id_service_id" UNIQUE(id, service_id), + CONSTRAINT "FK_service_languages_services_service_id" FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE RESTRICT); + CREATE UNIQUE INDEX "IX_service_languages_service_id_code" ON service_languages(service_id, code); + INSERT INTO service_languages(id, service_id, code, created, updated) + SELECT gen_random_uuid(), d.service_id, t.language_code, min(t.created), max(t.updated) + FROM email_templates t JOIN email_template_definitions d ON d.id = t.definition_id GROUP BY d.service_id, t.language_code; + ALTER TABLE email_templates ADD COLUMN service_id uuid, ADD COLUMN service_language_id uuid; + UPDATE email_templates t SET service_id = d.service_id, service_language_id = l.id + FROM email_template_definitions d JOIN service_languages l ON l.service_id = d.service_id + WHERE t.definition_id = d.id AND t.language_code = l.code; + ALTER TABLE email_templates ALTER COLUMN service_id SET NOT NULL, ALTER COLUMN service_language_id SET NOT NULL; + ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_email_template_definitions_definition_id_se~" + FOREIGN KEY (definition_id, service_id) REFERENCES email_template_definitions(id, service_id) ON DELETE CASCADE; + ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_service_languages_service_language_id_servi~" + FOREIGN KEY (service_language_id, service_id) REFERENCES service_languages(id, service_id) ON DELETE RESTRICT; + ALTER TABLE email_templates DROP CONSTRAINT "FK_email_templates_email_template_definitions_definition_id"; + DROP INDEX "IX_email_templates_definition_id_language_code"; + ALTER TABLE email_templates DROP COLUMN language_code; + CREATE UNIQUE INDEX "IX_email_templates_definition_id_service_language_id" ON email_templates(definition_id, service_language_id); + CREATE INDEX "IX_email_templates_definition_id_service_id" ON email_templates(definition_id, service_id); + CREATE INDEX "IX_email_templates_service_language_id_service_id" ON email_templates(service_language_id, service_id); + """; +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs index 125decf..52c206f 100644 --- a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs +++ b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "9.0.5") + .HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -114,6 +114,44 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations 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") @@ -125,28 +163,22 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations .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("Key") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("key"); + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); - b.Property("LanguageCode") - .IsRequired() - .HasMaxLength(10) - .HasColumnType("character varying(10)") - .HasColumnName("language_code"); - - b.Property("ServiceName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("service_name"); + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); b.Property("Subject") .IsRequired() @@ -164,12 +196,83 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations b.HasKey("Id"); - b.HasIndex("ServiceName", "Key", "LanguageCode") + 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.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.EmailChannelUsageEntity", b => { b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null) @@ -179,43 +282,91 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations .IsRequired(); }); - modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b => + 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.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 => { - b1.Property("EmailTemplateEntityId") - .HasColumnType("uuid"); + b1.Property("EmailTemplateDefinitionEntityId"); b1.Property("__synthesizedOrdinal") - .ValueGeneratedOnAdd() - .HasColumnType("integer"); + .ValueGeneratedOnAdd(); b1.Property("Name") .IsRequired() - .HasColumnType("text") - .HasAnnotation("Relational:JsonPropertyName", "name"); + .HasJsonPropertyName("name"); b1.Property("Required") - .HasColumnType("boolean") - .HasAnnotation("Relational:JsonPropertyName", "required"); + .HasJsonPropertyName("required"); - b1.HasKey("EmailTemplateEntityId", "__synthesizedOrdinal"); + b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal"); - b1.ToTable("email_templates"); + b1.ToTable("email_template_definitions"); - b1.ToJson("variables"); + b1 + .ToJson("variables") + .HasColumnType("jsonb"); b1.WithOwner() - .HasForeignKey("EmailTemplateEntityId"); + .HasForeignKey("EmailTemplateDefinitionEntityId"); }); + b.Navigation("Service"); + 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.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.EmailChannelEntity", b => { b.Navigation("UsageRecords"); }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b => + { + b.Navigation("Languages"); + }); + + modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => + { + b.Navigation("Languages"); + }); #pragma warning restore 612, 618 } } diff --git a/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs b/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs index bcf61e3..be060b3 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 ServiceLanguages => Set(); + internal DbSet Services => Set(); + internal DbSet TemplateDefinitions => Set(); internal DbSet Templates => Set(); internal DbSet Providers => Set(); internal DbSet EmailChannelUsage => Set(); diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index b7fe3a9..8ea2dc8 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -1,120 +1,232 @@ using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Templates; -using HrynCo.NotificationService.DAL.EF.Core; using HrynCo.NotificationService.DAL.EF.Entities; using Microsoft.EntityFrameworkCore; namespace HrynCo.NotificationService.DAL.EF.Repositories; -internal sealed class EmailTemplateRepository - : NotificationBaseRepository, IEmailTemplateRepository +internal sealed class EmailTemplateRepository(NotificationDbContext db, TemplateMutationRunner mutations) + : IEmailTemplateRepository, IEmailTemplateDefinitionRepository { - public EmailTemplateRepository(NotificationDbContext dbContext) : base(dbContext) - { - } + private IQueryable Languages => db.Templates + .Include(t => t.Definition).ThenInclude(d => d.Service).Include(t => t.ServiceLanguage); + private IQueryable Definitions => db.TemplateDefinitions + .Include(d => d.Service).ThenInclude(s => s.Languages) + .Include(d => d.Languages).ThenInclude(t => t.ServiceLanguage); public async Task> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) { - IQueryable query = EfRepository.Get(); - + var query = Languages.AsNoTracking(); if (!string.IsNullOrWhiteSpace(serviceName)) - { - query = query.Where(x => x.ServiceName == serviceName); - } - + query = query.Where(t => t.Definition.Service.Name == serviceName); if (!string.IsNullOrWhiteSpace(key)) - { - query = query.Where(x => x.Key == key); - } - - List entities = await query - .OrderBy(x => x.ServiceName).ThenBy(x => x.Key) - .AsNoTracking() - .ToListAsync(ct); - return entities.Select(MapToDomain).ToList(); + query = query.Where(t => t.Definition.Key == key); + var entities = await query.OrderBy(t => t.Definition.Service.Name).ThenBy(t => t.Definition.Key) + .ThenBy(t => t.ServiceLanguage.Code).ToListAsync(ct); + return entities.Select(MapLanguage).ToList(); } - public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) - { - List entities = await EfRepository.Get() - .AsNoTracking() - .Where(x => x.ServiceName == serviceName) - .ToListAsync(ct); - - return entities.Select(MapToDomain).ToList(); - } + public Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) => + GetAllAsync(serviceName, ct: ct); public async Task GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default) { - EmailTemplateEntity? entity = await EfRepository.Get() - .AsNoTracking() - .FirstOrDefaultAsync( - x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); - - return entity is null ? null : MapToDomain(entity); + var code = languageCode; + var entity = await Languages.AsNoTracking().SingleOrDefaultAsync(t => + t.Definition.Service.Name == serviceName && t.Definition.Key == key && t.ServiceLanguage.Code == code, ct); + return entity is null ? null : MapLanguage(entity); } - public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + public async Task GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) { - return EfRepository.AddAsync(MapToEntity(EmailTemplate)); + var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.Service.Name == serviceName && d.Key == key, ct); + return entity is null ? null : MapDefinition(entity); } - public async Task UpdateAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + public Task AddAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () => { - EmailTemplateEntity? entity = await EfRepository.Get() - .FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); - - if (entity is null) + ValidateLegacy(template); + var definition = await FindDefinitionAsync(template.ServiceName, template.Key, ct); + if (definition is null) { - return; + await RequireServiceLanguageAsync(template.ServiceName, template.LanguageCode, ct); + definition = await CreateDefinitionAsync(template.ServiceName, template.Key, template.Variables, ct); } + EnsureMatchingContract(definition, template.Variables); + await RequireServiceLanguageAsync(template.ServiceName, template.LanguageCode, ct); + var code = template.LanguageCode; + if (definition.Languages.Any(t => t.ServiceLanguage.Code == code)) + throw new EmailTemplateWriteException("This language already exists for the template."); + var language = NewLanguage(template, definition); + definition.Languages.Add(language); + db.Templates.Add(language); + Touch(definition); + }, ct); - entity.ServiceName = EmailTemplate.ServiceName; - entity.Key = EmailTemplate.Key; - entity.LanguageCode = EmailTemplate.LanguageCode; - entity.Subject = EmailTemplate.Subject; - entity.HtmlBody = EmailTemplate.HtmlBody; - entity.TextBody = EmailTemplate.TextBody; - entity.Variables = EmailTemplate.Variables - .Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required }) - .ToList(); + public Task UpdateAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () => + { + ValidateLegacy(template); + var definition = await RequireDefinitionAsync(template.ServiceName, template.Key, ct); + EnsureMatchingContract(definition, template.Variables); + var language = definition.Languages.SingleOrDefault(t => t.Id == template.Id) + ?? throw new EmailTemplateWriteException("The language changed or was removed. Reload the template."); + SetContent(language, template); + Touch(definition); + }, ct); - await EfRepository.SaveChangesAsync(); + public Task DeleteAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () => + { + var definition = await RequireDefinitionAsync(template.ServiceName, template.Key, ct); + var language = definition.Languages.SingleOrDefault(t => t.Id == template.Id) + ?? throw new EmailTemplateWriteException("The language changed or was removed. Reload the template."); + db.Templates.Remove(language); + Touch(definition); + }, ct); + + public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () => + { + EmailTemplateValidation.ValidateDefinition(definition); + await ValidateServiceLanguagesAsync(definition, ct); + var entity = await LoadForSaveAsync(definition, isNew, ct); + entity.Variables = MapVariables(definition.Variables); + SynchronizeLanguages(entity, definition.Languages); + Touch(entity); + definition.Id = entity.Id; + definition.Revision = entity.Revision; + }, ct); + + private async Task LoadForSaveAsync(EmailTemplateDefinition model, bool isNew, CancellationToken ct) + { + var entity = await FindDefinitionAsync(model.ServiceName, model.Key, ct); + if (isNew) + { + if (entity is not null) + throw new EmailTemplateWriteException("A template with this service and key already exists."); + return await CreateDefinitionAsync(model.ServiceName, model.Key, model.Variables, ct); + } + if (entity is null || entity.Id != model.Id || entity.Revision != model.Revision) + throw new EmailTemplateWriteException("This template changed after you opened it. Reload before saving; your entered values are retained."); + return entity; } - public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + private void SynchronizeLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList languages) { - EmailTemplateEntity? entity = await EfRepository.Get() - .FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); - if (entity is not null) - await EfRepository.DeleteAsync(entity); + var codes = languages.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal); + db.Templates.RemoveRange(definition.Languages.Where(t => !codes.Contains(t.ServiceLanguage.Code))); + foreach (var model in languages) + { + var code = model.LanguageCode; + var entity = definition.Languages.SingleOrDefault(t => t.ServiceLanguage.Code == code); + if (entity is null) + { + var language = NewLanguage(model, definition); + definition.Languages.Add(language); + db.Templates.Add(language); + } + else + SetContent(entity, model); + } } - private static EmailTemplate MapToDomain(EmailTemplateEntity e) => new() + public async Task> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) { - Id = e.Id, - ServiceName = e.ServiceName, - Key = e.Key, - LanguageCode = e.LanguageCode, - Subject = e.Subject, - HtmlBody = e.HtmlBody, - TextBody = e.TextBody, - Variables = e.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), - Created = e.Created, - Updated = e.Updated + var query = Definitions.AsNoTracking(); + if (!string.IsNullOrWhiteSpace(serviceName)) + query = query.Where(d => d.Service.Name == serviceName); + if (!string.IsNullOrWhiteSpace(key)) + query = query.Where(d => d.Key == key); + var entities = await query.OrderBy(d => d.Service.Name).ThenBy(d => d.Key).ToListAsync(ct); + return entities.Select(MapDefinition).ToList(); + } + + private async Task RequireServiceLanguageAsync(string serviceName, string code, CancellationToken ct) + { + if (!await db.ServiceLanguages.AnyAsync(l => l.Service.Name == serviceName && l.Code == code, ct)) + throw new EmailTemplateWriteException("Language is not in this service's catalog. Add it in the service editor first.", false); + } + + private async Task ValidateServiceLanguagesAsync(EmailTemplateDefinition definition, CancellationToken ct) + { + var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Name == definition.ServiceName + && (definition.ServiceId == Guid.Empty || s.Id == definition.ServiceId), ct) + ?? throw new EmailTemplateWriteException("Service does not exist. Create the service first.", false); + var codes = service.Languages.Select(l => l.Code).ToHashSet(StringComparer.Ordinal); + if (definition.Languages.Any(l => !codes.Contains(l.LanguageCode))) + throw new EmailTemplateWriteException("Language is not in this service's catalog. Add it in the service editor first.", false); + } + + private Task FindDefinitionAsync(string serviceName, string key, CancellationToken ct) => + Definitions.SingleOrDefaultAsync(d => d.Service.Name == serviceName && d.Key == key, ct); + + private async Task RequireDefinitionAsync(string serviceName, string key, CancellationToken ct) => + await FindDefinitionAsync(serviceName, key, ct) + ?? throw new EmailTemplateWriteException("The template was removed. Reload the template list."); + + private async Task CreateDefinitionAsync(string serviceName, string key, + IReadOnlyList variables, CancellationToken ct) + { + var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Name == serviceName, ct) + ?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false); + var entity = new EmailTemplateDefinitionEntity + { + Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow + }; + db.TemplateDefinitions.Add(entity); + return entity; + } + + private static void ValidateLegacy(EmailTemplate template) + { + EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key); + EmailTemplateValidation.ValidateLanguage(template); + EmailTemplateValidation.ValidateVariables(template.Variables); + } + + private static void EnsureMatchingContract(EmailTemplateDefinitionEntity definition, IReadOnlyList variables) + { + var shared = definition.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(); + if (!EmailTemplateValidation.ContractsEqual(shared, variables)) + throw new EmailTemplateWriteException("Variables conflict with the shared template contract. Edit shared variables in the template editor."); + } + + private static void Touch(EmailTemplateDefinitionEntity definition) + { + definition.Revision = Guid.NewGuid(); + definition.Updated = DateTimeOffset.UtcNow; + } + + private static List MapVariables(IReadOnlyList variables) => + variables.Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required }).ToList(); + + private static EmailTemplateEntity NewLanguage(EmailTemplate model, EmailTemplateDefinitionEntity definition) => new() + { + Id = model.Id, Definition = definition, ServiceId = definition.Service.Id, + ServiceLanguage = definition.Service.Languages.SingleOrDefault(l => l.Code == model.LanguageCode) + ?? throw new EmailTemplateWriteException("Language is not in this service's catalog. Add it in the service editor first.", false), + Subject = model.Subject, HtmlBody = model.HtmlBody, TextBody = model.TextBody, Created = model.Created }; - private static EmailTemplateEntity MapToEntity(EmailTemplate t) => new() + private static void SetContent(EmailTemplateEntity entity, EmailTemplate model) { - Id = t.Id, - ServiceName = t.ServiceName, - Key = t.Key, - LanguageCode = t.LanguageCode, - Subject = t.Subject, - HtmlBody = t.HtmlBody, - TextBody = t.TextBody, - Variables = t.Variables.Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required }).ToList(), - Created = t.Created, - Updated = t.Updated + entity.Subject = model.Subject; + entity.HtmlBody = model.HtmlBody; + entity.TextBody = model.TextBody; + entity.Updated = DateTimeOffset.UtcNow; + } + + private static EmailTemplate MapLanguage(EmailTemplateEntity entity) => new() + { + Id = entity.Id, ServiceName = entity.Definition.Service.Name, Key = entity.Definition.Key, + LanguageCode = entity.ServiceLanguage.Code, Subject = entity.Subject, HtmlBody = entity.HtmlBody, TextBody = entity.TextBody, + Variables = entity.Definition.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), + Created = entity.Created, Updated = entity.Updated + }; + + private static EmailTemplateDefinition MapDefinition(EmailTemplateDefinitionEntity entity) => new() + { + Id = entity.Id, ServiceId = entity.ServiceId, ServiceName = entity.Service.Name, Key = entity.Key, Revision = entity.Revision, + Variables = entity.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), + Languages = entity.Languages.OrderBy(t => t.ServiceLanguage.Code).Select(MapLanguage).ToList(), + Created = entity.Created, Updated = entity.Updated }; } diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs new file mode 100644 index 0000000..7db34d4 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs @@ -0,0 +1,188 @@ +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 NotificationServiceRepository(NotificationDbContext db, TemplateMutationRunner mutations) : INotificationServiceRepository +{ + public async Task> GetAllAsync(CancellationToken ct = default) + { + var services = await db.Services.AsNoTracking().Include(s => s.Languages).OrderBy(s => s.Name).ToListAsync(ct); + var counts = await VersionCountsAsync(ct); + return services.Select(s => Map(s, counts)).ToList(); + } + + public async Task GetAsync(Guid id, CancellationToken ct = default) + { + var service = await db.Services.AsNoTracking().Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == id, ct); + return service is null ? null : Map(service, await VersionCountsAsync(ct)); + } + + public Task SaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () => + { + Validate(model); + var service = await LoadForSaveAsync(model, isNew, ct); + await ValidateLanguageChangesAsync(service, model, ct); + await StageLanguageCodeChangesAsync(service, model.Languages, ct); + service.Name = model.Name; + SynchronizeLanguages(service, model.Languages); + Touch(service); + await TouchDefinitionsAsync(service.Id, ct); + model.Id = service.Id; + model.Revision = service.Revision; + }, ct); + + public Task RemoveLanguageAsync(Guid serviceId, Guid languageId, int expectedVersionCount, Guid expectedRevision, CancellationToken ct = default) => + mutations.ExecuteAsync(async () => + { + var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == serviceId, ct) + ?? throw new EmailTemplateWriteException("Service no longer exists. Reload services."); + 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) + throw new EmailTemplateWriteException("The service or affected version count changed. Review the current count and confirm again."); + db.Templates.RemoveRange(versions); + // Save dependent removals first because language deletion is restricted by its FK. + await db.SaveChangesAsync(ct); + db.ServiceLanguages.Remove(language); + Touch(service); + await TouchDefinitionsAsync(serviceId, ct); + }, ct); + + private async Task LoadForSaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct) + { + if (await db.Services.AnyAsync(s => s.Name == model.Name && s.Id != model.Id, ct)) + throw new EmailTemplateWriteException("A service with this name already exists."); + if (isNew) + { + var created = new ServiceEntity { Name = model.Name, Created = DateTimeOffset.UtcNow }; + db.Services.Add(created); + return created; + } + var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == model.Id, ct); + if (service is null || service.Revision != model.Revision) + throw new EmailTemplateWriteException("This service changed after you opened it. Reload before saving."); + return service; + } + + private async Task ValidateLanguageChangesAsync(ServiceEntity service, NotificationServiceInfo model, CancellationToken ct) + { + var ids = service.Languages.Select(l => l.Id).ToHashSet(); + if (model.Languages.Any(l => l.Id != Guid.Empty && !ids.Contains(l.Id))) + throw new EmailTemplateWriteException("A language changed or was removed. Reload the service."); + ValidateChangedCodes(service, model.Languages); + var retained = model.Languages.Select(l => l.Id).ToHashSet(); + 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); + if (count > 0) + throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation."); + } + } + + private async Task StageLanguageCodeChangesAsync(ServiceEntity service, IReadOnlyList languages, CancellationToken ct) + { + var requested = languages.Where(l => l.Id != Guid.Empty).ToDictionary(l => l.Id, l => l.Code); + var removed = service.Languages.Where(l => !requested.ContainsKey(l.Id)).ToList(); + var changed = service.Languages.Where(l => requested.TryGetValue(l.Id, out var code) && code != l.Code).ToList(); + if (removed.Count == 0 && changed.Count == 0) + return; + foreach (var language in removed) + { + db.ServiceLanguages.Remove(language); + service.Languages.Remove(language); + } + var reserved = service.Languages.Select(l => l.Code).Concat(languages.Select(l => l.Code)).ToHashSet(StringComparer.Ordinal); + foreach (var language in changed) + language.Code = TemporaryCode(reserved); + // Free old codes before final assignments, supporting swaps and replacing unused rows. + // These temporary identifiers remain invisible outside this transaction. + await db.SaveChangesAsync(ct); + } + + private static string TemporaryCode(HashSet reserved) + { + string code; + do { code = "~" + Guid.NewGuid().ToString("N")[..9]; } + while (!reserved.Add(code)); + return code; + } + + private void SynchronizeLanguages(ServiceEntity service, IReadOnlyList languages) + { + var retained = languages.Select(l => l.Id).ToHashSet(); + db.ServiceLanguages.RemoveRange(service.Languages.Where(l => !retained.Contains(l.Id))); + foreach (var model in languages) + { + var entity = service.Languages.SingleOrDefault(l => l.Id == model.Id); + if (entity is null) + db.ServiceLanguages.Add(new ServiceLanguageEntity { Service = service, Code = model.Code, Created = DateTimeOffset.UtcNow }); + else + entity.Code = model.Code; + } + } + + private async Task TouchDefinitionsAsync(Guid serviceId, CancellationToken ct) + { + var definitions = await db.TemplateDefinitions.Where(d => d.ServiceId == serviceId).ToListAsync(ct); + foreach (var definition in definitions) + { + 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() }) + .ToDictionaryAsync(g => g.Id, g => g.Count, ct); + + private static void Validate(NotificationServiceInfo service) + { + if (string.IsNullOrWhiteSpace(service.Name) || service.Name.Length > 100) + throw new EmailTemplateWriteException("Service name is required and must be at most 100 characters.", false); + foreach (var language in service.Languages) + ValidateCode(language.Code); + if (service.Languages.Select(l => l.Code).Distinct(StringComparer.Ordinal).Count() != service.Languages.Count) + throw new EmailTemplateWriteException("Language codes must be unique within the service.", false); + var ids = service.Languages.Where(l => l.Id != Guid.Empty).Select(l => l.Id).ToList(); + if (ids.Distinct().Count() != ids.Count) + throw new EmailTemplateWriteException("Each service language can appear only once.", false); + } + + private static void ValidateCode(string code) + { + if (string.IsNullOrWhiteSpace(code) || code.Length > 10) + throw new EmailTemplateWriteException("Language codes are required and must be at most 10 characters.", false); + } + + private static void ValidateChangedCodes(ServiceEntity service, IReadOnlyList languages) + { + var persisted = service.Languages.ToDictionary(l => l.Id, l => l.Code); + foreach (var language in languages) + { + if (persisted.TryGetValue(language.Id, out var original) && original == language.Code) + continue; + if (!System.Text.RegularExpressions.Regex.IsMatch(language.Code, "^[A-Za-z0-9]+(-[A-Za-z0-9]+)*$")) + throw new EmailTemplateWriteException("Use letters, numbers and hyphens in new or renamed language codes, without spaces. Codes are case-sensitive.", false); + } + } + + private static void Touch(ServiceEntity service) + { + service.Revision = Guid.NewGuid(); + service.Updated = DateTimeOffset.UtcNow; + } + + private static NotificationServiceInfo Map(ServiceEntity service, IReadOnlyDictionary counts) => new() + { + Id = service.Id, Name = service.Name, Revision = service.Revision, + Languages = service.Languages.OrderBy(l => l.Code).Select(l => new ServiceLanguageInfo + { + Id = l.Id, Code = l.Code, VersionCount = counts.GetValueOrDefault(l.Id) + }).ToList() + }; +} diff --git a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs index 0587f4e..d849308 100644 --- a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs +++ b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs @@ -16,7 +16,10 @@ public static class ServiceCollectionExtensions services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/HrynCo.NotificationService.DAL.EF/TemplateMutationRunner.cs b/HrynCo.NotificationService.DAL.EF/TemplateMutationRunner.cs new file mode 100644 index 0000000..b5ea822 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/TemplateMutationRunner.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; + +namespace HrynCo.NotificationService.DAL.EF; + +internal sealed class TemplateMutationRunner(NotificationDbContext db) +{ + public async Task ExecuteAsync(Func write, CancellationToken ct) + { + await using var transaction = db.Database.CurrentTransaction is null + ? await db.Database.BeginTransactionAsync(ct) : null; + // Service/language administration and template writes share the same transaction lock. + // Counts, catalog membership and revision checks are therefore valid until commit. + await db.Database.ExecuteSqlRawAsync("SELECT pg_advisory_xact_lock(1601)", ct); + await write(); + await db.SaveChangesAsync(ct); + if (transaction is not null) + await transaction.CommitAsync(ct); + } +} diff --git a/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs b/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs index 0df72ba..ade9233 100644 --- a/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs +++ b/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs @@ -12,15 +12,18 @@ internal sealed class DevelopmentDataSeeder private readonly IEmailChannelRepository _channels; private readonly IEmailTemplateRepository _templates; + private readonly INotificationServiceRepository _services; private readonly ILogger _logger; public DevelopmentDataSeeder( IEmailChannelRepository channels, IEmailTemplateRepository templates, + INotificationServiceRepository services, ILogger logger) { _channels = channels; _templates = templates; + _services = services; _logger = logger; } @@ -30,6 +33,7 @@ internal sealed class DevelopmentDataSeeder throw new InvalidOperationException("Development seed service name is not configured."); await SeedMailpitChannelAsync(serviceName, cancellationToken); + await SeedServiceAsync(serviceName, cancellationToken); foreach (EmailTemplate template in CreateTemplates(serviceName)) { @@ -51,6 +55,18 @@ internal sealed class DevelopmentDataSeeder } } + private async Task SeedServiceAsync(string serviceName, CancellationToken ct) + { + var services = await _services.GetAllAsync(ct); + var service = services.SingleOrDefault(s => s.Name == serviceName); + if (service?.Languages.Any(l => l.Code == DefaultLanguageCode) == true) + return; + var isNew = service is null; + service ??= new NotificationServiceInfo { Name = serviceName }; + service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = DefaultLanguageCode }]; + await _services.SaveAsync(service, isNew, ct); + } + private async Task SeedMailpitChannelAsync(string serviceName, CancellationToken cancellationToken) { IReadOnlyList existingChannels = await _channels.GetByServiceAsync( diff --git a/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs b/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs index 29e76ff..6e2d115 100644 --- a/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs +++ b/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs @@ -11,6 +11,7 @@ public sealed class DevelopmentDataSeederTests { private readonly IEmailChannelRepository _channels = Substitute.For(); private readonly IEmailTemplateRepository _templates = Substitute.For(); + private readonly INotificationServiceRepository _services = Substitute.For(); [Fact] public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate() @@ -69,6 +70,7 @@ public sealed class DevelopmentDataSeederTests private DevelopmentDataSeeder CreateSeeder() => new( _channels, _templates, + _services, NullLogger.Instance); private static bool IsMailpitChannel(EmailChannel channel) diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateServiceTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateServiceTests.cs index b22b9bc..436e223 100644 --- a/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateServiceTests.cs +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/EmailTemplateServiceTests.cs @@ -1,30 +1,97 @@ namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.Worker.Services.EmailProcessing; using NSubstitute; public sealed class EmailTemplateServiceTests { + [Theory] + [InlineData("RU")] + [InlineData(" ru ")] + [InlineData(" RU ")] + public async Task GetAsync_WhenExactVersionIsMissing_RetriesLegacyTrimmedLowercaseCode(string requestedCode) + { + IEmailTemplateRepository repository = Substitute.For(); + var expected = Template("ru"); + repository.GetAsync("TestService", "Welcome", "ru", Arg.Any()).Returns(expected); + var service = new EmailTemplateService(repository); + + Assert.Same(expected, await service.GetAsync("TestService", "Welcome", requestedCode, default)); + Assert.Equal(new[] { requestedCode, "ru" }, RequestedCodes(repository)); + } + [Fact] - public async Task GetAsync_WhenRequestedLanguageDoesNotExist_DoesNotSilentlyFallback() + public async Task GetAsync_WhenBothExactAndLowercaseVersionsExist_PrefersExactVersion() + { + IEmailTemplateRepository repository = Substitute.For(); + var expected = Template("RU"); + repository.GetAsync("TestService", "Welcome", "RU", Arg.Any()).Returns(expected); + repository.GetAsync("TestService", "Welcome", "ru", Arg.Any()).Returns(Template("ru")); + var service = new EmailTemplateService(repository); + + Assert.Same(expected, await service.GetAsync("TestService", "Welcome", "RU", default)); + Assert.Equal(new[] { "RU" }, RequestedCodes(repository)); + } + + [Fact] + public async Task GetAsync_WhenExactRegionalVersionExists_DoesNotNormalizeIt() + { + IEmailTemplateRepository repository = Substitute.For(); + var expected = Template("ru-RU"); + repository.GetAsync("TestService", "Welcome", "ru-RU", Arg.Any()).Returns(expected); + var service = new EmailTemplateService(repository); + + Assert.Same(expected, await service.GetAsync("TestService", "Welcome", "ru-RU", default)); + Assert.Equal(new[] { "ru-RU" }, RequestedCodes(repository)); + } + + [Fact] + public async Task GetAsync_WhenMissingCodeIsAlreadyCanonical_QueriesOnlyOnce() { IEmailTemplateRepository repository = Substitute.For(); var service = new EmailTemplateService(repository); - InvalidOperationException exception = await Assert.ThrowsAsync( - () => service.GetAsync("StoreMate-Prod", "EmailVerification", "UK", CancellationToken.None)); + var error = await Assert.ThrowsAsync(() => service.GetAsync("TestService", "Welcome", "zz", default)); - Assert.Contains("language='uk'", exception.Message); - await repository.Received(1).GetAsync( - "StoreMate-Prod", - "EmailVerification", - "uk", - CancellationToken.None); - await repository.DidNotReceive().GetAsync( - "StoreMate-Prod", - "EmailVerification", - "en", - Arg.Any()); + Assert.Contains("language='zz'", error.Message); + Assert.Equal(new[] { "zz" }, RequestedCodes(repository)); } + + [Fact] + public async Task GetAsync_WhenRegionalLookupMisses_DoesNotScanOtherCaseOrRegionalVariants() + { + IEmailTemplateRepository repository = Substitute.For(); + repository.GetAsync("TestService", "Welcome", "ru-RU", Arg.Any()).Returns(Template("ru-RU")); + repository.GetAsync("TestService", "Welcome", "ru", Arg.Any()).Returns(Template("ru")); + var service = new EmailTemplateService(repository); + + await Assert.ThrowsAsync(() => service.GetAsync("TestService", "Welcome", "RU-ru", default)); + + Assert.Equal(new[] { "RU-ru", "ru-ru" }, RequestedCodes(repository)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public async Task GetAsync_WhenCodeIsBlank_RejectsBeforeRepositoryLookup(string? code) + { + IEmailTemplateRepository repository = Substitute.For(); + var service = new EmailTemplateService(repository); + + await Assert.ThrowsAsync(() => service.GetAsync("TestService", "Welcome", code, default)); + + Assert.Empty(repository.ReceivedCalls()); + } + + private static EmailTemplate Template(string code) => new() + { + ServiceName = "TestService", Key = "Welcome", LanguageCode = code, Subject = "Subject " + code, + HtmlBody = "

Hello

", TextBody = "Hello" + }; + + private static string[] RequestedCodes(IEmailTemplateRepository repository) => repository.ReceivedCalls() + .Select(call => (string)call.GetArguments()[2]!).ToArray(); } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs index 7800374..4574ec0 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Create/CreateEmailTemplateHandler.cs @@ -39,7 +39,14 @@ internal sealed class CreateEmailTemplateHandler Variables = request.Variables }; - await _templates.AddAsync(template, cancellationToken); + try + { + await _templates.AddAsync(template, cancellationToken); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } return Success(template.Id); } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateCommand.cs b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateCommand.cs index e52dc73..7820c28 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateCommand.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateCommand.cs @@ -7,5 +7,6 @@ namespace HrynCo.NotificationService.Services.EmailTemplates.Delete; public sealed record DeleteEmailTemplateCommand( string ServiceName, string Key, - string LanguageCode + string LanguageCode, + Guid? ExpectedVersionId = null ) : IRequest>; diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs index b22b3c6..3dd4f98 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Delete/DeleteEmailTemplateHandler.cs @@ -1,3 +1,4 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -27,8 +28,17 @@ internal sealed class DeleteEmailTemplateHandler if (template is null) return Failure("Template not found.", ServiceErrorCode.NotFound); + if (request.ExpectedVersionId is Guid expectedId && template.Id != expectedId) + return Failure("Template changed after you opened it. Reload before deleting.", ServiceErrorCode.Conflict); - await _templates.DeleteAsync(template, cancellationToken); + try + { + await _templates.DeleteAsync(template, cancellationToken); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } return Success(Unit.Value); } diff --git a/HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionCommand.cs b/HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionCommand.cs new file mode 100644 index 0000000..5689d74 --- /dev/null +++ b/HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionCommand.cs @@ -0,0 +1,8 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.EmailTemplates.SaveDefinition; + +public sealed record SaveEmailTemplateDefinitionCommand(EmailTemplateDefinition Definition, bool IsNew) + : IRequest>; diff --git a/HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionHandler.cs new file mode 100644 index 0000000..30f797c --- /dev/null +++ b/HrynCo.NotificationService.Services/EmailTemplates/SaveDefinition/SaveEmailTemplateDefinitionHandler.cs @@ -0,0 +1,26 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Services.Core; +using HrynCo.NotificationService.Services.Logging; +using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; + +namespace HrynCo.NotificationService.Services.EmailTemplates.SaveDefinition; + +internal sealed class SaveEmailTemplateDefinitionHandler( + IContextualSerilogLogger logger, + IEmailTemplateDefinitionRepository definitions) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(SaveEmailTemplateDefinitionCommand request, CancellationToken cancellationToken) + { + try + { + await definitions.SaveDefinitionAsync(request.Definition, request.IsNew, cancellationToken); + return Success(request.Definition.Id); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs index 1cd842d..7810769 100644 --- a/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs +++ b/HrynCo.NotificationService.Services/EmailTemplates/Update/UpdateEmailTemplateHandler.cs @@ -1,3 +1,4 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.DAL.Abstract; using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.Services.Core; @@ -34,7 +35,14 @@ internal sealed class UpdateEmailTemplateHandler template.Variables = request.Variables; template.Updated = DateTimeOffset.UtcNow; - await _templates.UpdateAsync(template, cancellationToken); + try + { + await _templates.UpdateAsync(template, cancellationToken); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } return Success(Unit.Value); } diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs index 65f2ce2..34f819a 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminTemplatesIndexViewTests.cs @@ -3,30 +3,12 @@ namespace HrynCo.NotificationService.Web.IntegrationTests; public sealed class AdminTemplatesIndexViewTests { [Fact] - public void CreateAndEditLinks_UseExplicitFilterQueryInterpolation() + public void CreateAndEditLinks_KeepTheirServiceAndKeyFilter() { - string view = File.ReadAllText(FindIndexView()); - - Assert.Contains("/admin/templates/create@(filterQuery)", view); - Assert.Contains("@t.LanguageCode@(filterQuery)", view); - Assert.DoesNotContain("create@filterQuery", view); - Assert.DoesNotContain("LanguageCode@filterQuery", view); - } - - private static string FindIndexView() - { - DirectoryInfo? directory = new(AppContext.BaseDirectory); - while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "HrynCo.NotificationService.slnx"))) - { - directory = directory.Parent; - } - - Assert.NotNull(directory); - return Path.Combine( - directory.FullName, - "HrynCo.NotificationService.Web", - "Views", - "AdminTemplates", - "Index.cshtml"); + 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.DoesNotContain("/admin/templates", view); } } diff --git a/HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs new file mode 100644 index 0000000..3e4877e --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/EditorLayoutTests.cs @@ -0,0 +1,68 @@ +using System.Text.RegularExpressions; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.Abstract.Templates; +using Microsoft.Extensions.DependencyInjection; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + [Fact] + public async Task ServiceAndTemplateEditorsRenderOneBreadcrumbBeforeTheCard_IncludingValidationErrors() + { + var connection = await fixture.CreateDatabaseAsync(); + await using var app = new TemplateWebApplicationFactory(connection); + using var client = app.CreateClient(); + using var scope = app.Services.CreateScope(); + var service = (await scope.ServiceProvider.GetRequiredService().GetAllAsync()).Single(); + var definition = new EmailTemplateDefinition { ServiceName = service.Name, Key = "Layout" }; + await scope.ServiceProvider.GetRequiredService().SaveDefinitionAsync(definition, true); + var root = $"/admin/services/{service.Id}"; + var editors = new[] + { + (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") + }; + foreach (var editor in editors) + AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form); + var fields = TemplateEditorTestFields.Read(await client.GetStringAsync(root + "/templates/create")); + fields["Key"] = "InvalidDraft"; + 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"); + Assert.Contains("
", errorHtml); + Assert.Contains("
Service
", errorHtml); + Assert.Contains("
TestService
", errorHtml); + Assert.DoesNotContain("form-control-plaintext", errorHtml); + Assert.Equal(service.Name, TemplateEditorTestFields.Read(errorHtml)["ServiceName"]); + } + + [Fact] + public async Task ChannelEditorWorksWithoutTheOptionalBreadcrumbSection() + { + var connection = await fixture.CreateDatabaseAsync(); + await using var app = new TemplateWebApplicationFactory(connection); + using var client = app.CreateClient(); + var html = await client.GetStringAsync("/admin/channels/create"); + Assert.Contains("

", html); + Assert.Contains("Create Channel", html); + Assert.Contains("id=\"channelForm\"", html); + Assert.DoesNotContain("aria-label=\"Breadcrumb\"", html); + } + + private static void AssertEditorLayout(string html, string title, string form) + { + Assert.Single(Regex.Matches(html, "aria-label=\"Breadcrumb\"")); + var breadcrumb = html.IndexOf("