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..c850bc8 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailTemplateDefinitionRepository.cs @@ -0,0 +1,14 @@ +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 GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default); + Task UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default); + Task> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default); + Task DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, 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..c6c2465 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/INotificationServiceRepository.cs @@ -0,0 +1,13 @@ +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 GetByNameAsync(string name, CancellationToken ct = default); + Task DeleteAsync(Guid id, Guid revision, 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/EmailTemplateDefinitionPatch.cs b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinitionPatch.cs new file mode 100644 index 0000000..29c8522 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Templates/EmailTemplateDefinitionPatch.cs @@ -0,0 +1,11 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Templates; + +public sealed class EmailTemplateDefinitionPatch +{ + public Guid ServiceId { get; init; } + public required string Key { get; init; } + public Guid? Revision { get; init; } + public required IReadOnlyList Variables { get; init; } + public required IReadOnlyList Translations { get; init; } + public IReadOnlyList RemoveLanguageCodes { get; init; } = []; +} 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/EmailChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs index 8234425..cca409c 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs @@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository GetByIdAsync(Guid id, CancellationToken ct = default) { - EmailChannelEntity? entity = await EfRepository.GetByIdAsync(id); + EmailChannelEntity? entity = await EfRepository.Get().AsNoTracking().SingleOrDefaultAsync(c => c.Id == id, ct); return entity is null ? null : MapToDomain(entity); } diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs index b7fe3a9..388c271 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailTemplateRepository.cs @@ -1,120 +1,324 @@ 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 async Task GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) { - EmailTemplateEntity? entity = await EfRepository.Get() - .FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); + var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct); + return entity is null ? null : MapDefinition(entity); + } - if (entity is null) + public async Task UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) + { + EmailTemplateDefinitionEntity? saved = null; + await mutations.ExecuteAsync(async () => { - return; + var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == patch.ServiceId, ct); + if (service is null) + return; + ValidatePatch(patch, service); + var entity = await LoadForUpsertAsync(patch, service, ct); + entity.Variables = MapVariables(patch.Variables); + var removed = patch.RemoveLanguageCodes.ToHashSet(StringComparer.Ordinal); + db.Templates.RemoveRange(entity.Languages.Where(t => removed.Contains(t.ServiceLanguage.Code))); + // Omitted translations are retained. Only explicitly supplied content is changed. + UpsertLanguages(entity, patch.Translations); + Touch(entity); + saved = entity; + }, ct); + return saved is null ? null : MapDefinition(saved); + } + + private async Task LoadForUpsertAsync(EmailTemplateDefinitionPatch patch, ServiceEntity service, CancellationToken ct) + { + var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == patch.ServiceId && d.Key == patch.Key, ct); + if (patch.Revision is null) + { + if (entity is not null) + throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating."); + entity = new EmailTemplateDefinitionEntity { Service = service, ServiceId = service.Id, Key = patch.Key, Created = DateTimeOffset.UtcNow }; + db.TemplateDefinitions.Add(entity); + return entity; } - - 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(); - - await EfRepository.SaveChangesAsync(); + if (entity is null || entity.Revision != patch.Revision) + throw new EmailTemplateWriteException("The definition is missing or its revision changed. Read it again before updating."); + return entity; } - public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) + private static void ValidatePatch(EmailTemplateDefinitionPatch patch, ServiceEntity service) { - EmailTemplateEntity? entity = await EfRepository.Get() - .FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); - if (entity is not null) - await EfRepository.DeleteAsync(entity); + EmailTemplateValidation.ValidateDefinition(new EmailTemplateDefinition + { + ServiceId = service.Id, ServiceName = service.Name, Key = patch.Key, + Variables = patch.Variables, Languages = patch.Translations + }); + if (patch.Revision == Guid.Empty) + throw new EmailTemplateWriteException("Revision must be null for creation or a nonempty revision from the last read for update.", false); + var removed = patch.RemoveLanguageCodes; + if (removed.Any(string.IsNullOrWhiteSpace) || removed.Distinct(StringComparer.Ordinal).Count() != removed.Count) + throw new EmailTemplateWriteException("removeLanguageCodes must contain unique nonblank language codes.", false); + var supplied = patch.Translations.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal); + if (removed.Any(supplied.Contains)) + throw new EmailTemplateWriteException("A language cannot be supplied and removed in the same request.", false); + var supported = service.Languages.Select(l => l.Code).ToHashSet(StringComparer.Ordinal); + if (supplied.Concat(removed).Any(code => !supported.Contains(code))) + throw new EmailTemplateWriteException("Every supplied or removed language must exactly match a code in this service's catalog.", false); } - private static EmailTemplate MapToDomain(EmailTemplateEntity e) => new() + public async Task> GetDefinitionsAsync(Guid serviceId, 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 entities = await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct); + return entities.Select(MapDefinition).ToList(); + } + + public async Task DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) + { + var removed = false; + await mutations.ExecuteAsync(async () => + { + if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || revision == Guid.Empty) + throw new EmailTemplateWriteException("An exact key (at most 100 characters) and a nonempty revision are required.", false); + var definition = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct); + if (definition is null) + return; + if (definition.Revision != revision) + throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting."); + db.TemplateDefinitions.Remove(definition); + removed = true; + }, ct); + return removed; + } + + public Task AddAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () => + { + ValidateLegacy(template); + var definition = await FindDefinitionAsync(template.ServiceName, template.Key, ct); + if (definition is null) + { + 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); + + 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); + + 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; + } + + private void SynchronizeLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList languages) + { + var codes = languages.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal); + db.Templates.RemoveRange(definition.Languages.Where(t => !codes.Contains(t.ServiceLanguage.Code))); + UpsertLanguages(definition, languages); + } + + private void UpsertLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList languages) + { + 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); + } + } + + public async Task> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) + { + 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..8c77c61 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/NotificationServiceRepository.cs @@ -0,0 +1,215 @@ +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 async Task GetByNameAsync(string name, CancellationToken ct = default) + { + var service = await db.Services.AsNoTracking().Include(s => s.Languages).SingleOrDefaultAsync(s => s.Name == name, ct); + return service is null ? null : Map(service, await VersionCountsAsync(ct)); + } + + public async Task DeleteAsync(Guid id, Guid revision, CancellationToken ct = default) + { + var removed = false; + await mutations.ExecuteAsync(async () => + { + if (revision == Guid.Empty) + throw new EmailTemplateWriteException("A nonempty service revision is required.", false); + var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == id, ct); + if (service is null) + return; + if (service.Revision != revision) + throw new EmailTemplateWriteException("The service revision changed. Read it again before deleting."); + if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct)) + throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations."); + db.ServiceLanguages.RemoveRange(service.Languages); + db.Services.Remove(service); + removed = true; + }, ct); + return removed; + } + + 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.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionCommand.cs new file mode 100644 index 0000000..d4468a9 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionCommand.cs @@ -0,0 +1,7 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; +using Unit = HrynCo.NotificationService.Services.Core.Unit; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record DeleteEmailTemplateDefinitionCommand(Guid ServiceId, string Key, Guid Revision) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionHandler.cs new file mode 100644 index 0000000..2801302 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteEmailTemplateDefinitionHandler.cs @@ -0,0 +1,25 @@ +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.TemplateDefinitions; + +internal sealed class DeleteEmailTemplateDefinitionHandler( + IContextualSerilogLogger logger, IEmailTemplateDefinitionRepository repository) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteEmailTemplateDefinitionCommand request, CancellationToken cancellationToken) + { + try + { + var removed = await repository.DeleteDefinitionAsync(request.ServiceId, request.Key, request.Revision, cancellationToken); + return removed ? Success(Unit.Value) : Failure("Template definition not found.", ServiceErrorCode.NotFound); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceCommand.cs new file mode 100644 index 0000000..a3e39db --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceCommand.cs @@ -0,0 +1,7 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; +using Unit = HrynCo.NotificationService.Services.Core.Unit; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record DeleteNotificationServiceCommand(Guid ServiceId, Guid Revision) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceHandler.cs new file mode 100644 index 0000000..b3fd872 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteNotificationServiceHandler.cs @@ -0,0 +1,25 @@ +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.TemplateDefinitions; + +internal sealed class DeleteNotificationServiceHandler( + IContextualSerilogLogger logger, INotificationServiceRepository repository) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteNotificationServiceCommand request, CancellationToken cancellationToken) + { + try + { + var removed = await repository.DeleteAsync(request.ServiceId, request.Revision, cancellationToken); + return removed ? Success(Unit.Value) : Failure("Service not found.", ServiceErrorCode.NotFound); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsCommand.cs new file mode 100644 index 0000000..d974c90 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsCommand.cs @@ -0,0 +1,7 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; +using Unit = HrynCo.NotificationService.Services.Core.Unit; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record DeleteServiceLanguageVersionsCommand(Guid ServiceId, Guid LanguageId, Guid Revision, int ExpectedVersionCount, bool ConfirmDelete) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsHandler.cs new file mode 100644 index 0000000..a6e59d2 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteServiceLanguageVersionsHandler.cs @@ -0,0 +1,30 @@ +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.TemplateDefinitions; + +internal sealed class DeleteServiceLanguageVersionsHandler( + IContextualSerilogLogger logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteServiceLanguageVersionsCommand request, CancellationToken cancellationToken) + { + if (!request.ConfirmDelete || request.ExpectedVersionCount < 0 || request.Revision == Guid.Empty) + return Failure("ConfirmDelete must be true; a nonnegative expectedVersionCount and nonempty service revision are required.", ServiceErrorCode.InvalidRequest); + var service = await services.GetAsync(request.ServiceId, cancellationToken); + if (service?.Languages.Any(l => l.Id == request.LanguageId) != true) + return Failure("Service language not found.", ServiceErrorCode.NotFound); + try + { + await services.RemoveLanguageAsync(request.ServiceId, request.LanguageId, request.ExpectedVersionCount, request.Revision, cancellationToken); + return Success(Unit.Value); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageCommand.cs new file mode 100644 index 0000000..533c98d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageCommand.cs @@ -0,0 +1,7 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; +using Unit = HrynCo.NotificationService.Services.Core.Unit; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record DeleteUnusedServiceLanguageCommand(Guid ServiceId, Guid LanguageId, Guid Revision) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageHandler.cs new file mode 100644 index 0000000..35be77d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/DeleteUnusedServiceLanguageHandler.cs @@ -0,0 +1,34 @@ +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.TemplateDefinitions; + +internal sealed class DeleteUnusedServiceLanguageHandler( + IContextualSerilogLogger logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(DeleteUnusedServiceLanguageCommand request, CancellationToken cancellationToken) + { + if (request.Revision == Guid.Empty) + return Failure("A nonempty service revision is required.", ServiceErrorCode.InvalidRequest); + var service = await services.GetAsync(request.ServiceId, cancellationToken); + var language = service?.Languages.SingleOrDefault(l => l.Id == request.LanguageId); + if (language is null) + return Failure("Service language not found.", ServiceErrorCode.NotFound); + if (language.VersionCount > 0) + return Failure("This language has template versions. Use delete-with-versions with explicit confirmation and the current count.", ServiceErrorCode.Conflict); + try + { + // The repository repeats the zero-count and revision checks under its mutation lock. + await services.RemoveLanguageAsync(request.ServiceId, request.LanguageId, 0, request.Revision, cancellationToken); + return Success(Unit.Value); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/EmailTemplateDefinitionDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/EmailTemplateDefinitionDto.cs new file mode 100644 index 0000000..f8c9960 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/EmailTemplateDefinitionDto.cs @@ -0,0 +1,5 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record EmailTemplateDefinitionDto(Guid Id, Guid ServiceId, string ServiceName, string Key, Guid Revision, + IReadOnlyList Variables, IReadOnlyList Translations, + DateTimeOffset Created, DateTimeOffset? Updated); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesHandler.cs new file mode 100644 index 0000000..074ab29 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesHandler.cs @@ -0,0 +1,17 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.Services.Core; +using HrynCo.NotificationService.Services.Logging; +using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +internal sealed class GetAllNotificationServicesHandler( + IContextualSerilogLogger logger, INotificationServiceRepository services) + : RequestHandler>>(logger) +{ + protected override async Task>> DoOnHandle(GetAllNotificationServicesQuery request, CancellationToken cancellationToken) + { + var all = await services.GetAllAsync(cancellationToken); + return Success>(all.Select(NotificationServiceMapping.Map).ToList()); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesQuery.cs new file mode 100644 index 0000000..f88d5df --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetAllNotificationServicesQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetAllNotificationServicesQuery() : IRequest>>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionHandler.cs new file mode 100644 index 0000000..890edf7 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionHandler.cs @@ -0,0 +1,19 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.Services.Core; +using HrynCo.NotificationService.Services.Logging; +using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +internal sealed class GetEmailTemplateDefinitionHandler( + IContextualSerilogLogger logger, IEmailTemplateDefinitionRepository definitions) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(GetEmailTemplateDefinitionQuery request, CancellationToken cancellationToken) + { + var definition = await definitions.GetDefinitionAsync(request.ServiceId, request.Key, cancellationToken); + return definition is null + ? Failure("Template definition not found.", ServiceErrorCode.NotFound) + : Success(TemplateDefinitionMapping.Map(definition)); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionQuery.cs new file mode 100644 index 0000000..cb19cf3 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetEmailTemplateDefinitionQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetEmailTemplateDefinitionQuery(Guid ServiceId, string Key) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceHandler.cs new file mode 100644 index 0000000..a2cc45a --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceHandler.cs @@ -0,0 +1,23 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.Services.Core; +using HrynCo.NotificationService.Services.Logging; +using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +internal sealed class GetNotificationServiceHandler( + IContextualSerilogLogger logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(GetNotificationServiceQuery request, CancellationToken cancellationToken) + { + if (request.ServiceId is null && string.IsNullOrWhiteSpace(request.Name)) + return Failure("An exact service name is required.", ServiceErrorCode.InvalidRequest); + var service = request.ServiceId is Guid id + ? await services.GetAsync(id, cancellationToken) + : await services.GetByNameAsync(request.Name!, cancellationToken); + return service is null + ? Failure("Service not found.", ServiceErrorCode.NotFound) + : Success(NotificationServiceMapping.Map(service)); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceQuery.cs new file mode 100644 index 0000000..2bb2e0b --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetNotificationServiceQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetNotificationServiceQuery(Guid? ServiceId, string? Name) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsHandler.cs new file mode 100644 index 0000000..7407e09 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsHandler.cs @@ -0,0 +1,20 @@ +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.Services.Core; +using HrynCo.NotificationService.Services.Logging; +using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +internal sealed class GetServiceTemplateDefinitionsHandler( + IContextualSerilogLogger logger, + INotificationServiceRepository services, IEmailTemplateDefinitionRepository definitions) + : RequestHandler>>(logger) +{ + protected override async Task>> DoOnHandle(GetServiceTemplateDefinitionsQuery request, CancellationToken cancellationToken) + { + if (await services.GetAsync(request.ServiceId, cancellationToken) is null) + return Failure>("Service not found.", ServiceErrorCode.NotFound); + var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken); + return Success>(all.Select(TemplateDefinitionMapping.Map).ToList()); + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsQuery.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsQuery.cs new file mode 100644 index 0000000..856034d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/GetServiceTemplateDefinitionsQuery.cs @@ -0,0 +1,6 @@ +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record GetServiceTemplateDefinitionsQuery(Guid ServiceId) : IRequest>>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceDto.cs new file mode 100644 index 0000000..38f1b81 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceDto.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record NotificationServiceDto(Guid Id, string Name, Guid Revision, IReadOnlyList SupportedLanguageCodes, IReadOnlyList Languages); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceMapping.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceMapping.cs new file mode 100644 index 0000000..01af249 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/NotificationServiceMapping.cs @@ -0,0 +1,10 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +internal static class NotificationServiceMapping +{ + public static NotificationServiceDto Map(NotificationServiceInfo service) => + new(service.Id, service.Name, service.Revision, service.Languages.Select(l => l.Code).ToList(), + service.Languages.Select(l => new ServiceLanguageDto(l.Id, l.Code, l.VersionCount)).ToList()); +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceCommand.cs new file mode 100644 index 0000000..1c3b975 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceCommand.cs @@ -0,0 +1,7 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record SaveNotificationServiceCommand(NotificationServiceInfo Service, bool IsNew) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceHandler.cs new file mode 100644 index 0000000..c49d4ff --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/SaveNotificationServiceHandler.cs @@ -0,0 +1,30 @@ +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.TemplateDefinitions; + +internal sealed class SaveNotificationServiceHandler( + IContextualSerilogLogger logger, INotificationServiceRepository services) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(SaveNotificationServiceCommand request, CancellationToken cancellationToken) + { + if (!request.IsNew && request.Service.Revision == Guid.Empty) + return Failure("A nonempty service revision is required for update.", ServiceErrorCode.InvalidRequest); + if (!request.IsNew && await services.GetAsync(request.Service.Id, cancellationToken) is null) + return Failure("Service not found.", ServiceErrorCode.NotFound); + try + { + await services.SaveAsync(request.Service, request.IsNew, cancellationToken); + var saved = await services.GetAsync(request.Service.Id, cancellationToken); + return Success(NotificationServiceMapping.Map(saved!)); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/ServiceLanguageDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/ServiceLanguageDto.cs new file mode 100644 index 0000000..346c9e9 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/ServiceLanguageDto.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record ServiceLanguageDto(Guid Id, string Code, int VersionCount); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateDefinitionMapping.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateDefinitionMapping.cs new file mode 100644 index 0000000..99cd669 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateDefinitionMapping.cs @@ -0,0 +1,12 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +internal static class TemplateDefinitionMapping +{ + public static EmailTemplateDefinitionDto Map(EmailTemplateDefinition definition) => new( + definition.Id, definition.ServiceId, definition.ServiceName, definition.Key, definition.Revision, + definition.Variables.Select(v => new TemplateVariableDto(v.Name, v.Required)).ToList(), + definition.Languages.Select(t => new TemplateTranslationDto(t.Id, t.LanguageCode, t.Subject, t.HtmlBody, t.TextBody, t.Created, t.Updated)).ToList(), + definition.Created, definition.Updated); +} diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateTranslationDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateTranslationDto.cs new file mode 100644 index 0000000..8d506c5 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateTranslationDto.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record TemplateTranslationDto(Guid Id, string LanguageCode, string Subject, string HtmlBody, string TextBody, DateTimeOffset Created, DateTimeOffset? Updated); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateVariableDto.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateVariableDto.cs new file mode 100644 index 0000000..7b029e2 --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/TemplateVariableDto.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record TemplateVariableDto(string Name, bool Required); diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionCommand.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionCommand.cs new file mode 100644 index 0000000..b8c3a8d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionCommand.cs @@ -0,0 +1,7 @@ +using HrynCo.NotificationService.DAL.Abstract.Templates; +using HrynCo.NotificationService.Services.Core; +using MediatR; + +namespace HrynCo.NotificationService.Services.TemplateDefinitions; + +public sealed record UpsertEmailTemplateDefinitionCommand(EmailTemplateDefinitionPatch Patch) : IRequest>; diff --git a/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionHandler.cs b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionHandler.cs new file mode 100644 index 0000000..32a340d --- /dev/null +++ b/HrynCo.NotificationService.Services/TemplateDefinitions/UpsertEmailTemplateDefinitionHandler.cs @@ -0,0 +1,27 @@ +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.TemplateDefinitions; + +internal sealed class UpsertEmailTemplateDefinitionHandler( + IContextualSerilogLogger logger, IEmailTemplateDefinitionRepository definitions) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(UpsertEmailTemplateDefinitionCommand request, CancellationToken cancellationToken) + { + try + { + var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken); + return definition is null + ? Failure("Service not found.", ServiceErrorCode.NotFound) + : Success(TemplateDefinitionMapping.Map(definition)); + } + catch (EmailTemplateWriteException error) + { + return Failure(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs new file mode 100644 index 0000000..e39ccb6 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs @@ -0,0 +1,137 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.RegularExpressions; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + [Fact] + public async Task DefinitionDeleteRequiresAntiforgeryAndConfirmation_AndPreservesFilter() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk")); + var definition = await context.ReadAsync(); + var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete"; + var html = await client.GetStringAsync(url + "?keyFilter=Welcome"); + Assert.Contains("2 translation(s)", html); + Assert.Contains("Languages: en, uk", html); + var fields = TemplateEditorTestFields.Read(html); + Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(url, new FormUrlEncodedContent(new Dictionary { ["ConfirmDelete"] = "true" }))).StatusCode); + var unconfirmed = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync(); + Assert.Contains("Confirm deletion", unconfirmed); + AssertConfirmationReset(unconfirmed); + fields = TemplateEditorTestFields.Read(unconfirmed); + fields["ConfirmDelete"] = "true"; + fields["Key"] = "Different"; + var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields)); + Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode); + Assert.Equal($"/admin/services/{context.ServiceId}?key=Welcome", deleted.Headers.Location!.OriginalString); + Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(url)).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await client.GetAsync($"/admin/services/{context.ServiceId}")).StatusCode); + } + + [Fact] + public async Task StaleDefinitionConfirmationRefreshesCountRevisionAndUncheckedConfirmation() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + await context.UpsertAsync(DefinitionApiTestContext.Body(null)); + var definition = await context.ReadAsync(); + var url = $"/admin/services/{context.ServiceId}/templates/{definition.GetProperty("id").GetGuid()}/delete"; + var fields = TemplateEditorTestFields.Read(await context.Client.GetStringAsync(url)); + var oldRevision = fields["Revision"]; + await context.UpsertAsync(DefinitionApiTestContext.Body(definition.GetProperty("revision").GetGuid(), "en")); + fields["ConfirmDelete"] = "true"; + var html = await (await context.Client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync(); + Assert.Contains("revision changed", html); + Assert.Contains("1 translation(s)", html); + Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(html)["Revision"]); + AssertConfirmationReset(html); + Assert.Single((await context.ReadAsync()).GetProperty("translations").EnumerateArray()); + var wrongScope = $"/admin/services/{Guid.NewGuid()}/templates/{definition.GetProperty("id").GetGuid()}/delete"; + Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(wrongScope)).StatusCode); + Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsync(wrongScope, new FormUrlEncodedContent(fields))).StatusCode); + } + + [Fact] + public async Task ServiceDeleteIsBlockedByEmptyDefinition_AndRequiresFreshConfirmationAfterRename() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + await context.UpsertAsync(DefinitionApiTestContext.Body(null)); + var url = $"/admin/services/{context.ServiceId}/delete"; + var blocked = await client.GetStringAsync(url); + Assert.Contains("1 template definition(s)", blocked); + Assert.Contains("Email channels are retained", blocked); + var fields = TemplateEditorTestFields.Read(blocked); + fields["ConfirmDelete"] = "true"; + Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync()); + var definition = await context.ReadAsync(); + await client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid()); + fields = TemplateEditorTestFields.Read(await client.GetStringAsync(url)); + var oldRevision = fields["Revision"]; + using (var scope = context.App.Services.CreateScope()) + { + var services = scope.ServiceProvider.GetRequiredService(); + var service = (await services.GetAsync(context.ServiceId))!; + service.Name = "Renamed Before Delete"; + await services.SaveAsync(service, false); + } + fields["ConfirmDelete"] = "true"; + var stale = await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync(); + Assert.Contains("revision changed", stale); + Assert.Contains("Renamed Before Delete", stale); + Assert.NotEqual(oldRevision, TemplateEditorTestFields.Read(stale)["Revision"]); + AssertConfirmationReset(stale); + fields = TemplateEditorTestFields.Read(stale); + Assert.Contains("Confirm deletion", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync()); + fields["ConfirmDelete"] = "true"; + var deleted = await client.PostAsync(url, new FormUrlEncodedContent(fields)); + Assert.Equal(HttpStatusCode.Redirect, deleted.StatusCode); + Assert.Equal("/admin/services", deleted.Headers.Location!.OriginalString); + } + + [Fact] + public async Task EmptyStatesOfferScopedFirstCreateOrClearFilterActions() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var root = $"/admin/services/{context.ServiceId}"; + var templates = await context.Client.GetStringAsync(root); + Assert.Contains("No email templates for this service yet.", templates); + Assert.Contains("Create First Template", templates); + Assert.Contains(root + "/templates/create", templates); + Assert.Contains("template-key-filter", templates); + var filtered = await context.Client.GetStringAsync(root + "?key=Missing"); + Assert.Contains("No templates match this key.", filtered); + Assert.Contains(">Clear filter", filtered); + Assert.DoesNotContain("Create First Template", filtered); + var service = (await context.Client.GetFromJsonAsync(context.ServiceRoot)).GetProperty("data"); + await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid()); + var services = await context.Client.GetStringAsync("/admin/services"); + Assert.Contains("No services yet.", services); + Assert.Contains("Create First Service", services); + Assert.Contains("aria-hidden=\"true\"", services); + } + + [Fact] + public async Task TemplateLoadFailureDoesNotPretendTheServiceHasNoTemplates() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + using var failing = context.App.WithWebHostBuilder(builder => builder.ConfigureTestServices(services => + services.AddScoped())); + using var client = failing.CreateClient(); + var html = await client.GetStringAsync($"/admin/services/{context.ServiceId}"); + Assert.Contains("Could not load templates", html); + Assert.Contains("Templates are unavailable", html); + Assert.DoesNotContain("Create First Template", html); + Assert.DoesNotContain("No email templates for this service yet.", html); + } + + private static void AssertConfirmationReset(string html) => + Assert.DoesNotContain("checked", Regex.Match(html, "]+type=\"checkbox\"[^>]*>").Value); +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs new file mode 100644 index 0000000..8be9409 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs @@ -0,0 +1,60 @@ +using System.Net; +using System.Text.RegularExpressions; +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.DAL.EF; +using Microsoft.Extensions.DependencyInjection; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : IClassFixture +{ + [Fact] + public async Task LayoutLinksToApiDocumentationInNewTab_AndScalarIsAvailable() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var pages = new[] { "/admin/services", "/admin/channels", "/admin/services/create", "/admin/channels/create" }; + foreach (var page in pages) + { + var html = await context.Client.GetStringAsync(page); + Assert.Single(Regex.Matches(html, "class=\"admin-api-documentation\"")); + Assert.Contains("href=\"/scalar/v1\" target=\"_blank\" rel=\"noopener noreferrer\"", html); + Assert.Contains("aria-label=\"API documentation (opens in a new tab)\">API documentation", html); + } + Assert.Equal(HttpStatusCode.OK, (await context.Client.GetAsync("/scalar/v1")).StatusCode); + } + + [Fact] + public async Task BothListTablesHaveScopedGridBorders_AndRetainTemplateHeaderOnFilterMiss() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + var services = await context.Client.GetStringAsync("/admin/services"); + Assert.Contains("table table-hover admin-data-table", services); + var templates = await context.Client.GetStringAsync($"/admin/services/{context.ServiceId}?key=Missing"); + Assert.Contains("table table-hover admin-data-table service-template-grid", templates); + Assert.Contains("class=\"template-actions\"", templates); + Assert.Contains("No templates match this key.", templates); + Assert.Contains("class=\"template-key-filter\"", templates); + } + [Fact] + public async Task AllFourDataTablesShareTheGridClassAndScopedColumnHeaders() + { + await using var context = await DefinitionApiTestContext.CreateAsync(fixture); + using var scope = context.App.Services.CreateScope(); + await scope.ServiceProvider.GetRequiredService().AddAsync(new EmailChannel + { + ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp, + Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" } + }); + await scope.ServiceProvider.GetRequiredService().SaveChangesAsync(); + var pages = new[] { "/admin/services", $"/admin/services/{context.ServiceId}", "/admin/channels", $"/admin/services/{context.ServiceId}/edit" }; + foreach (var page in pages) + { + var html = await context.Client.GetStringAsync(page); + Assert.Single(Regex.Matches(html, "]+admin-data-table")); + Assert.Equal(Regex.Matches(html, "]").Count, Regex.Matches(html, "", html); + } + } + +} 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/DefinitionApiTestContext.cs b/HrynCo.NotificationService.Web.IntegrationTests/DefinitionApiTestContext.cs new file mode 100644 index 0000000..71f3aa3 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/DefinitionApiTestContext.cs @@ -0,0 +1,47 @@ +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace HrynCo.NotificationService.Web.IntegrationTests; + +public sealed class DefinitionApiTestContext : IAsyncDisposable +{ + public required TemplateWebApplicationFactory App { get; init; } + public required HttpClient Client { get; init; } + public required Guid ServiceId { get; init; } + public string ServiceRoot => $"/api/v1/services/{ServiceId}"; + + public static async Task CreateAsync(TemplateDatabaseFixture fixture) + { + var app = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync()); + var client = app.CreateClient(); + var response = await client.GetFromJsonAsync("/api/v1/services?name=TestService"); + return new DefinitionApiTestContext { App = app, Client = client, ServiceId = response.GetProperty("data").GetProperty("id").GetGuid() }; + } + + public string DefinitionUrl(string key = "Welcome", bool upsert = false) => + ServiceRoot + "/email-template-definitions" + (upsert ? "/upsert" : "") + "?key=" + Uri.EscapeDataString(key); + + public Task UpsertAsync(JsonNode body, string key = "Welcome") => + Client.PostAsJsonAsync(DefinitionUrl(key, upsert: true), body); + + public async Task ReadAsync(string key = "Welcome") => + (await Client.GetFromJsonAsync(DefinitionUrl(key))).GetProperty("data"); + + public static JsonObject Body(Guid? revision, params string[] codes) => new() + { + ["revision"] = revision?.ToString(), + ["variables"] = new JsonArray(new JsonObject { ["name"] = "Name", ["required"] = true }), + ["translations"] = new JsonArray(codes.Select(code => (JsonNode)new JsonObject + { + ["languageCode"] = code, ["subject"] = "Subject " + code, + ["htmlBody"] = "

Hello {{Name}}

", ["textBody"] = "Hello {{Name}}" + }).ToArray()) + }; + + public async ValueTask DisposeAsync() + { + Client.Dispose(); + await App.DisposeAsync(); + } +} 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("