Compare commits

...

6 Commits

Author SHA1 Message Date
agrynco 8e81572386 feat(notification): add admin rest management and deletion
Add revision-safe service and template APIs, channel mapping, and validated admin controls.

Ref: IT-1601
2026-10-17 22:51:01 +03:00
agrynco 81654c0d33 feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage.

Ref: IT-1601
2026-10-17 22:02:43 +03:00
agrynco 36810dc5f1 Merge pull request 'feat(notification): enable Invemory production publisher' (#21) from feature/it-1493-notification-k8s into main 2026-10-03 15:19:39 +03:00
agrynco 8d9ed96f2c feat(notification): enable production publisher
Preserve production broker definitions and allow bounded SMTP delivery.

Ref: IT-1508
2026-10-03 15:18:03 +03:00
agrynco a684caf7f7 Merge pull request 'chore: preserve current configuration and documentation' (#20) from feature/it-1493-notification-k8s into main 2026-10-03 00:59:44 +03:00
agrynco 27f3603d8f chore: preserve current workspace changes
Save pending configuration, deployment, and documentation work.
2026-10-03 00:56:28 +03:00
148 changed files with 9021 additions and 976 deletions
+10 -9
View File
@@ -1,9 +1,10 @@
**/.git **/.git
**/.vs **/.vs
**/.idea **/.idea
**/bin **/bin
**/obj **/obj
**/*.user **/*.user
**/*.suo **/*.suo
**/TestResults **/TestResults
NuGet.Config NuGet.Config
docker/kubernetes/
+3 -1
View File
@@ -36,7 +36,9 @@
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="NSubstitute" Version="5.3.0" /> <PackageVersion Include="NSubstitute" Version="5.3.0" />
<!-- Patched test-only dependency pulled by Testcontainers. -->
<PackageVersion Include="SSH.NET" Version="2026.0.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" /> <PackageVersion Include="Testcontainers.PostgreSql" Version="4.6.0" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.5" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.6" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,14 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IEmailTemplateDefinitionRepository
{
Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default);
Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default);
Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default);
Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default);
Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default);
Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default);
Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default);
}
@@ -0,0 +1,13 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface INotificationServiceRepository
{
Task<IReadOnlyList<NotificationServiceInfo>> GetAllAsync(CancellationToken ct = default);
Task<NotificationServiceInfo?> GetAsync(Guid id, CancellationToken ct = default);
Task<NotificationServiceInfo?> GetByNameAsync(string name, CancellationToken ct = default);
Task<bool> 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);
}
@@ -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<EmailTemplateVariable> Variables { get; set; } = [];
public IReadOnlyList<EmailTemplate> Languages { get; set; } = [];
}
@@ -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<EmailTemplateVariable> Variables { get; init; }
public required IReadOnlyList<EmailTemplate> Translations { get; init; }
public IReadOnlyList<string> RemoveLanguageCodes { get; init; } = [];
}
@@ -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<EmailTemplateVariable> 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<List<EmailTemplateVariable>>(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<EmailTemplateVariable>? 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<EmailTemplateVariable> left, IReadOnlyList<EmailTemplateVariable> 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);
}
}
@@ -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;
}
@@ -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<ServiceLanguageInfo> Languages { get; set; } = [];
}
@@ -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; }
}
@@ -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<EmailTemplateDefinitionEntity>
{
public void Configure(EntityTypeBuilder<EmailTemplateDefinitionEntity> 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");
});
}
}
@@ -4,53 +4,27 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations; namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal class EmailTemplateEntityConfiguration : IEntityTypeConfiguration<EmailTemplateEntity> internal sealed class EmailTemplateEntityConfiguration : IEntityTypeConfiguration<EmailTemplateEntity>
{ {
public void Configure(EntityTypeBuilder<EmailTemplateEntity> builder) public void Configure(EntityTypeBuilder<EmailTemplateEntity> builder)
{ {
builder.ToTable("email_templates"); builder.ToTable("email_templates");
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id"); builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.DefinitionId).HasColumnName("definition_id");
builder.Property(x => x.ServiceName) builder.Property(x => x.ServiceId).HasColumnName("service_id");
.HasColumnName("service_name") builder.Property(x => x.ServiceLanguageId).HasColumnName("service_language_id");
.IsRequired() builder.HasIndex(x => new { x.DefinitionId, x.ServiceLanguageId }).IsUnique();
.HasMaxLength(100); builder.HasOne(x => x.Definition).WithMany(x => x.Languages)
.HasForeignKey(x => new { x.DefinitionId, x.ServiceId })
builder.Property(x => x.Key) .HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Cascade);
.HasColumnName("key") builder.HasOne(x => x.ServiceLanguage).WithMany()
.IsRequired() .HasForeignKey(x => new { x.ServiceLanguageId, x.ServiceId })
.HasMaxLength(100); .HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Subject).HasColumnName("subject").IsRequired();
builder.Property(x => x.LanguageCode) builder.Property(x => x.HtmlBody).HasColumnName("html_body").IsRequired();
.HasColumnName("language_code") builder.Property(x => x.TextBody).HasColumnName("text_body").IsRequired();
.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.Created).HasColumnName("created"); builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated"); 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");
});
} }
} }
@@ -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<ServiceEntity>
{
public void Configure(EntityTypeBuilder<ServiceEntity> 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");
}
}
@@ -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<ServiceLanguageEntity>
{
public void Configure(EntityTypeBuilder<ServiceLanguageEntity> 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");
}
}
@@ -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<EmailTemplateVariableData> Variables { get; set; } = [];
public List<EmailTemplateEntity> Languages { get; set; } = [];
}
@@ -4,17 +4,12 @@ namespace HrynCo.NotificationService.DAL.EF.Entities;
internal class EmailTemplateEntity : Entity internal class EmailTemplateEntity : Entity
{ {
public required string ServiceName { get; set; } public Guid DefinitionId { get; set; }
public required string Key { get; set; } public EmailTemplateDefinitionEntity Definition { get; set; } = null!;
public required string LanguageCode { get; set; } 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 Subject { get; set; }
public required string HtmlBody { get; set; } public required string HtmlBody { get; set; }
public required string TextBody { get; set; } public required string TextBody { get; set; }
public List<EmailTemplateVariableData> Variables { get; set; } = [];
}
internal class EmailTemplateVariableData
{
public required string Name { get; set; }
public bool Required { get; set; }
} }
@@ -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; }
}
@@ -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<ServiceLanguageEntity> Languages { get; set; } = [];
}
@@ -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; }
}
@@ -0,0 +1,376 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasColumnName("daily_limit");
b.Property<int>("EmailChannelType")
.HasColumnType("integer")
.HasColumnName("provider_type");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<int?>("MonthlyLimit")
.HasColumnType("integer")
.HasColumnName("monthly_limit");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid")
.HasColumnName("provider_id");
b.Property<int>("SentCount")
.HasColumnType("integer")
.HasColumnName("sent_count");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("html_body");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("TextBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text_body");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("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<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("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
}
}
}
@@ -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);
""";
}
@@ -17,7 +17,7 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "9.0.5") .HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -114,6 +114,44 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.ToTable("email_channel_usage", (string)null); b.ToTable("email_channel_usage", (string)null);
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("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 => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -125,28 +163,22 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("created"); .HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody") b.Property<string>("HtmlBody")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("html_body"); .HasColumnName("html_body");
b.Property<string>("Key") b.Property<Guid>("ServiceId")
.IsRequired() .HasColumnType("uuid")
.HasMaxLength(100) .HasColumnName("service_id");
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<string>("LanguageCode") b.Property<Guid>("ServiceLanguageId")
.IsRequired() .HasColumnType("uuid")
.HasMaxLength(10) .HasColumnName("service_language_id");
.HasColumnType("character varying(10)")
.HasColumnName("language_code");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<string>("Subject") b.Property<string>("Subject")
.IsRequired() .IsRequired()
@@ -164,12 +196,83 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ServiceName", "Key", "LanguageCode") b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique(); .IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("email_templates", (string)null); b.ToTable("email_templates", (string)null);
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("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 => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{ {
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null) b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
@@ -179,43 +282,91 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
.IsRequired(); .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 => b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{ {
b1.Property<Guid>("EmailTemplateEntityId") b1.Property<Guid>("EmailTemplateDefinitionEntityId");
.HasColumnType("uuid");
b1.Property<int>("__synthesizedOrdinal") b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd();
.HasColumnType("integer");
b1.Property<string>("Name") b1.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasJsonPropertyName("name");
.HasAnnotation("Relational:JsonPropertyName", "name");
b1.Property<bool>("Required") b1.Property<bool>("Required")
.HasColumnType("boolean") .HasJsonPropertyName("required");
.HasAnnotation("Relational:JsonPropertyName", "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() b1.WithOwner()
.HasForeignKey("EmailTemplateEntityId"); .HasForeignKey("EmailTemplateDefinitionEntityId");
}); });
b.Navigation("Service");
b.Navigation("Variables"); 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 => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{ {
b.Navigation("UsageRecords"); 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 #pragma warning restore 612, 618
} }
} }
@@ -10,6 +10,9 @@ public class NotificationDbContext : DbContext
{ {
} }
internal DbSet<ServiceLanguageEntity> ServiceLanguages => Set<ServiceLanguageEntity>();
internal DbSet<ServiceEntity> Services => Set<ServiceEntity>();
internal DbSet<EmailTemplateDefinitionEntity> TemplateDefinitions => Set<EmailTemplateDefinitionEntity>();
internal DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>(); internal DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>();
internal DbSet<EmailChannelEntity> Providers => Set<EmailChannelEntity>(); internal DbSet<EmailChannelEntity> Providers => Set<EmailChannelEntity>();
internal DbSet<EmailChannelUsageEntity> EmailChannelUsage => Set<EmailChannelUsageEntity>(); internal DbSet<EmailChannelUsageEntity> EmailChannelUsage => Set<EmailChannelUsageEntity>();
@@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default) public async Task<EmailChannel?> 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); return entity is null ? null : MapToDomain(entity);
} }
@@ -1,120 +1,324 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF.Core;
using HrynCo.NotificationService.DAL.EF.Entities; using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories; namespace HrynCo.NotificationService.DAL.EF.Repositories;
internal sealed class EmailTemplateRepository internal sealed class EmailTemplateRepository(NotificationDbContext db, TemplateMutationRunner mutations)
: NotificationBaseRepository<EmailTemplateEntity>, IEmailTemplateRepository : IEmailTemplateRepository, IEmailTemplateDefinitionRepository
{ {
public EmailTemplateRepository(NotificationDbContext dbContext) : base(dbContext) private IQueryable<EmailTemplateEntity> Languages => db.Templates
{ .Include(t => t.Definition).ThenInclude(d => d.Service).Include(t => t.ServiceLanguage);
} private IQueryable<EmailTemplateDefinitionEntity> Definitions => db.TemplateDefinitions
.Include(d => d.Service).ThenInclude(s => s.Languages)
.Include(d => d.Languages).ThenInclude(t => t.ServiceLanguage);
public async Task<IReadOnlyList<EmailTemplate>> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) public async Task<IReadOnlyList<EmailTemplate>> GetAllAsync(string? serviceName = null, string? key = null, CancellationToken ct = default)
{ {
IQueryable<EmailTemplateEntity> query = EfRepository.Get(); var query = Languages.AsNoTracking();
if (!string.IsNullOrWhiteSpace(serviceName)) if (!string.IsNullOrWhiteSpace(serviceName))
{ query = query.Where(t => t.Definition.Service.Name == serviceName);
query = query.Where(x => x.ServiceName == serviceName);
}
if (!string.IsNullOrWhiteSpace(key)) if (!string.IsNullOrWhiteSpace(key))
{ query = query.Where(t => t.Definition.Key == key);
query = query.Where(x => x.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();
List<EmailTemplateEntity> entities = await query
.OrderBy(x => x.ServiceName).ThenBy(x => x.Key)
.AsNoTracking()
.ToListAsync(ct);
return entities.Select(MapToDomain).ToList();
} }
public async Task<IReadOnlyList<EmailTemplate>> GetByServiceAsync(string serviceName, CancellationToken ct = default) public Task<IReadOnlyList<EmailTemplate>> GetByServiceAsync(string serviceName, CancellationToken ct = default) =>
{ GetAllAsync(serviceName, ct: ct);
List<EmailTemplateEntity> entities = await EfRepository.Get()
.AsNoTracking()
.Where(x => x.ServiceName == serviceName)
.ToListAsync(ct);
return entities.Select(MapToDomain).ToList();
}
public async Task<EmailTemplate?> GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default) public async Task<EmailTemplate?> GetAsync(string serviceName, string key, string languageCode, CancellationToken ct = default)
{ {
EmailTemplateEntity? entity = await EfRepository.Get() var code = languageCode;
.AsNoTracking() var entity = await Languages.AsNoTracking().SingleOrDefaultAsync(t =>
.FirstOrDefaultAsync( t.Definition.Service.Name == serviceName && t.Definition.Key == key && t.ServiceLanguage.Code == code, ct);
x => x.ServiceName == serviceName && x.Key == key && x.LanguageCode == languageCode, ct); return entity is null ? null : MapLanguage(entity);
return entity is null ? null : MapToDomain(entity);
} }
public Task AddAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) public async Task<EmailTemplateDefinition?> 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<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default)
{ {
EmailTemplateEntity? entity = await EfRepository.Get() var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
.FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); return entity is null ? null : MapDefinition(entity);
}
if (entity is null) public async Task<EmailTemplateDefinition?> 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<EmailTemplateDefinitionEntity> 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;
} }
if (entity is null || entity.Revision != patch.Revision)
entity.ServiceName = EmailTemplate.ServiceName; throw new EmailTemplateWriteException("The definition is missing or its revision changed. Read it again before updating.");
entity.Key = EmailTemplate.Key; return entity;
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();
} }
public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default) private static void ValidatePatch(EmailTemplateDefinitionPatch patch, ServiceEntity service)
{ {
EmailTemplateEntity? entity = await EfRepository.Get() EmailTemplateValidation.ValidateDefinition(new EmailTemplateDefinition
.FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct); {
if (entity is not null) ServiceId = service.Id, ServiceName = service.Name, Key = patch.Key,
await EfRepository.DeleteAsync(entity); 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<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default)
{ {
Id = e.Id, var entities = await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct);
ServiceName = e.ServiceName, return entities.Select(MapDefinition).ToList();
Key = e.Key, }
LanguageCode = e.LanguageCode,
Subject = e.Subject, public async Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default)
HtmlBody = e.HtmlBody, {
TextBody = e.TextBody, var removed = false;
Variables = e.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(), await mutations.ExecuteAsync(async () =>
Created = e.Created, {
Updated = e.Updated 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<EmailTemplateDefinitionEntity> 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<EmailTemplate> 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<EmailTemplate> 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<IReadOnlyList<EmailTemplateDefinition>> 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<EmailTemplateDefinitionEntity?> FindDefinitionAsync(string serviceName, string key, CancellationToken ct) =>
Definitions.SingleOrDefaultAsync(d => d.Service.Name == serviceName && d.Key == key, ct);
private async Task<EmailTemplateDefinitionEntity> 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<EmailTemplateDefinitionEntity> CreateDefinitionAsync(string serviceName, string key,
IReadOnlyList<EmailTemplateVariable> 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<EmailTemplateVariable> 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<EmailTemplateVariableData> MapVariables(IReadOnlyList<EmailTemplateVariable> 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, entity.Subject = model.Subject;
ServiceName = t.ServiceName, entity.HtmlBody = model.HtmlBody;
Key = t.Key, entity.TextBody = model.TextBody;
LanguageCode = t.LanguageCode, entity.Updated = DateTimeOffset.UtcNow;
Subject = t.Subject, }
HtmlBody = t.HtmlBody,
TextBody = t.TextBody, private static EmailTemplate MapLanguage(EmailTemplateEntity entity) => new()
Variables = t.Variables.Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required }).ToList(), {
Created = t.Created, Id = entity.Id, ServiceName = entity.Definition.Service.Name, Key = entity.Definition.Key,
Updated = t.Updated 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
}; };
} }
@@ -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<IReadOnlyList<NotificationServiceInfo>> 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<NotificationServiceInfo?> 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<NotificationServiceInfo?> 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<bool> 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<ServiceEntity> 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<ServiceLanguageInfo> 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<string> reserved)
{
string code;
do { code = "~" + Guid.NewGuid().ToString("N")[..9]; }
while (!reserved.Add(code));
return code;
}
private void SynchronizeLanguages(ServiceEntity service, IReadOnlyList<ServiceLanguageInfo> 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<Dictionary<Guid, int>> 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<ServiceLanguageInfo> 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<Guid, int> 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()
};
}
@@ -16,7 +16,10 @@ public static class ServiceCollectionExtensions
services.AddDbContext<NotificationDbContext>(options => services.AddDbContext<NotificationDbContext>(options =>
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
services.AddScoped<TemplateMutationRunner>();
services.AddScoped<INotificationServiceRepository, NotificationServiceRepository>();
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>(); services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>();
services.AddScoped<IEmailChannelRepository, EmailChannelRepository>(); services.AddScoped<IEmailChannelRepository, EmailChannelRepository>();
services.AddScoped<IEmailChannelUsageRepository, EmailChannelUsageRepository>(); services.AddScoped<IEmailChannelUsageRepository, EmailChannelUsageRepository>();
services.AddScoped<IUnitOfWork, NotificationUnitOfWork>(); services.AddScoped<IUnitOfWork, NotificationUnitOfWork>();
@@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF;
internal sealed class TemplateMutationRunner(NotificationDbContext db)
{
public async Task ExecuteAsync(Func<Task> 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);
}
}
@@ -12,15 +12,18 @@ internal sealed class DevelopmentDataSeeder
private readonly IEmailChannelRepository _channels; private readonly IEmailChannelRepository _channels;
private readonly IEmailTemplateRepository _templates; private readonly IEmailTemplateRepository _templates;
private readonly INotificationServiceRepository _services;
private readonly ILogger<DevelopmentDataSeeder> _logger; private readonly ILogger<DevelopmentDataSeeder> _logger;
public DevelopmentDataSeeder( public DevelopmentDataSeeder(
IEmailChannelRepository channels, IEmailChannelRepository channels,
IEmailTemplateRepository templates, IEmailTemplateRepository templates,
INotificationServiceRepository services,
ILogger<DevelopmentDataSeeder> logger) ILogger<DevelopmentDataSeeder> logger)
{ {
_channels = channels; _channels = channels;
_templates = templates; _templates = templates;
_services = services;
_logger = logger; _logger = logger;
} }
@@ -30,6 +33,7 @@ internal sealed class DevelopmentDataSeeder
throw new InvalidOperationException("Development seed service name is not configured."); throw new InvalidOperationException("Development seed service name is not configured.");
await SeedMailpitChannelAsync(serviceName, cancellationToken); await SeedMailpitChannelAsync(serviceName, cancellationToken);
await SeedServiceAsync(serviceName, cancellationToken);
foreach (EmailTemplate template in CreateTemplates(serviceName)) 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) private async Task SeedMailpitChannelAsync(string serviceName, CancellationToken cancellationToken)
{ {
IReadOnlyList<EmailChannel> existingChannels = await _channels.GetByServiceAsync( IReadOnlyList<EmailChannel> existingChannels = await _channels.GetByServiceAsync(
@@ -11,6 +11,7 @@ public sealed class DevelopmentDataSeederTests
{ {
private readonly IEmailChannelRepository _channels = Substitute.For<IEmailChannelRepository>(); private readonly IEmailChannelRepository _channels = Substitute.For<IEmailChannelRepository>();
private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>(); private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>();
private readonly INotificationServiceRepository _services = Substitute.For<INotificationServiceRepository>();
[Fact] [Fact]
public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate() public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate()
@@ -69,6 +70,7 @@ public sealed class DevelopmentDataSeederTests
private DevelopmentDataSeeder CreateSeeder() => new( private DevelopmentDataSeeder CreateSeeder() => new(
_channels, _channels,
_templates, _templates,
_services,
NullLogger<DevelopmentDataSeeder>.Instance); NullLogger<DevelopmentDataSeeder>.Instance);
private static bool IsMailpitChannel(EmailChannel channel) private static bool IsMailpitChannel(EmailChannel channel)
@@ -1,30 +1,97 @@
namespace HrynCo.NotificationService.Services.Tests.EmailProcessing; namespace HrynCo.NotificationService.Services.Tests.EmailProcessing;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.EmailProcessing; using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using NSubstitute; using NSubstitute;
public sealed class EmailTemplateServiceTests public sealed class EmailTemplateServiceTests
{ {
[Theory]
[InlineData("RU")]
[InlineData(" ru ")]
[InlineData(" RU ")]
public async Task GetAsync_WhenExactVersionIsMissing_RetriesLegacyTrimmedLowercaseCode(string requestedCode)
{
IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
var expected = Template("ru");
repository.GetAsync("TestService", "Welcome", "ru", Arg.Any<CancellationToken>()).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] [Fact]
public async Task GetAsync_WhenRequestedLanguageDoesNotExist_DoesNotSilentlyFallback() public async Task GetAsync_WhenBothExactAndLowercaseVersionsExist_PrefersExactVersion()
{
IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
var expected = Template("RU");
repository.GetAsync("TestService", "Welcome", "RU", Arg.Any<CancellationToken>()).Returns(expected);
repository.GetAsync("TestService", "Welcome", "ru", Arg.Any<CancellationToken>()).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<IEmailTemplateRepository>();
var expected = Template("ru-RU");
repository.GetAsync("TestService", "Welcome", "ru-RU", Arg.Any<CancellationToken>()).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<IEmailTemplateRepository>(); IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
var service = new EmailTemplateService(repository); var service = new EmailTemplateService(repository);
InvalidOperationException exception = await Assert.ThrowsAsync<InvalidOperationException>( var error = await Assert.ThrowsAsync<InvalidOperationException>(() => service.GetAsync("TestService", "Welcome", "zz", default));
() => service.GetAsync("StoreMate-Prod", "EmailVerification", "UK", CancellationToken.None));
Assert.Contains("language='uk'", exception.Message); Assert.Contains("language='zz'", error.Message);
await repository.Received(1).GetAsync( Assert.Equal(new[] { "zz" }, RequestedCodes(repository));
"StoreMate-Prod",
"EmailVerification",
"uk",
CancellationToken.None);
await repository.DidNotReceive().GetAsync(
"StoreMate-Prod",
"EmailVerification",
"en",
Arg.Any<CancellationToken>());
} }
[Fact]
public async Task GetAsync_WhenRegionalLookupMisses_DoesNotScanOtherCaseOrRegionalVariants()
{
IEmailTemplateRepository repository = Substitute.For<IEmailTemplateRepository>();
repository.GetAsync("TestService", "Welcome", "ru-RU", Arg.Any<CancellationToken>()).Returns(Template("ru-RU"));
repository.GetAsync("TestService", "Welcome", "ru", Arg.Any<CancellationToken>()).Returns(Template("ru"));
var service = new EmailTemplateService(repository);
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<IEmailTemplateRepository>();
var service = new EmailTemplateService(repository);
await Assert.ThrowsAsync<InvalidDataException>(() => 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 = "<p>Hello</p>", TextBody = "Hello"
};
private static string[] RequestedCodes(IEmailTemplateRepository repository) => repository.ReceivedCalls()
.Select(call => (string)call.GetArguments()[2]!).ToArray();
} }
@@ -39,7 +39,14 @@ internal sealed class CreateEmailTemplateHandler
Variables = request.Variables Variables = request.Variables
}; };
await _templates.AddAsync(template, cancellationToken); try
{
await _templates.AddAsync(template, cancellationToken);
}
catch (EmailTemplateWriteException error)
{
return Failure<Guid>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
return Success(template.Id); return Success(template.Id);
} }
@@ -7,5 +7,6 @@ namespace HrynCo.NotificationService.Services.EmailTemplates.Delete;
public sealed record DeleteEmailTemplateCommand( public sealed record DeleteEmailTemplateCommand(
string ServiceName, string ServiceName,
string Key, string Key,
string LanguageCode string LanguageCode,
Guid? ExpectedVersionId = null
) : IRequest<ServiceResult<Unit>>; ) : IRequest<ServiceResult<Unit>>;
@@ -1,3 +1,4 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.DAL.Abstract; using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
@@ -27,8 +28,17 @@ internal sealed class DeleteEmailTemplateHandler
if (template is null) if (template is null)
return Failure<Unit>("Template not found.", ServiceErrorCode.NotFound); return Failure<Unit>("Template not found.", ServiceErrorCode.NotFound);
if (request.ExpectedVersionId is Guid expectedId && template.Id != expectedId)
return Failure<Unit>("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<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
return Success(Unit.Value); return Success(Unit.Value);
} }
@@ -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<ServiceResult<Guid>>;
@@ -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<SaveEmailTemplateDefinitionCommand> logger,
IEmailTemplateDefinitionRepository definitions)
: RequestHandler<SaveEmailTemplateDefinitionCommand, ServiceResult<Guid>>(logger)
{
protected override async Task<ServiceResult<Guid>> DoOnHandle(SaveEmailTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
await definitions.SaveDefinitionAsync(request.Definition, request.IsNew, cancellationToken);
return Success(request.Definition.Id);
}
catch (EmailTemplateWriteException error)
{
return Failure<Guid>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -1,3 +1,4 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.DAL.Abstract; using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
@@ -34,7 +35,14 @@ internal sealed class UpdateEmailTemplateHandler
template.Variables = request.Variables; template.Variables = request.Variables;
template.Updated = DateTimeOffset.UtcNow; template.Updated = DateTimeOffset.UtcNow;
await _templates.UpdateAsync(template, cancellationToken); try
{
await _templates.UpdateAsync(template, cancellationToken);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
return Success(Unit.Value); return Success(Unit.Value);
} }
@@ -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<ServiceResult<Unit>>;
@@ -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<DeleteEmailTemplateDefinitionCommand> logger, IEmailTemplateDefinitionRepository repository)
: RequestHandler<DeleteEmailTemplateDefinitionCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteEmailTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteDefinitionAsync(request.ServiceId, request.Key, request.Revision, cancellationToken);
return removed ? Success(Unit.Value) : Failure<Unit>("Template definition not found.", ServiceErrorCode.NotFound);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -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<ServiceResult<Unit>>;
@@ -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<DeleteNotificationServiceCommand> logger, INotificationServiceRepository repository)
: RequestHandler<DeleteNotificationServiceCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteNotificationServiceCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteAsync(request.ServiceId, request.Revision, cancellationToken);
return removed ? Success(Unit.Value) : Failure<Unit>("Service not found.", ServiceErrorCode.NotFound);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -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<ServiceResult<Unit>>;
@@ -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<DeleteServiceLanguageVersionsCommand> logger, INotificationServiceRepository services)
: RequestHandler<DeleteServiceLanguageVersionsCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteServiceLanguageVersionsCommand request, CancellationToken cancellationToken)
{
if (!request.ConfirmDelete || request.ExpectedVersionCount < 0 || request.Revision == Guid.Empty)
return Failure<Unit>("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<Unit>("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<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -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<ServiceResult<Unit>>;
@@ -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<DeleteUnusedServiceLanguageCommand> logger, INotificationServiceRepository services)
: RequestHandler<DeleteUnusedServiceLanguageCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeleteUnusedServiceLanguageCommand request, CancellationToken cancellationToken)
{
if (request.Revision == Guid.Empty)
return Failure<Unit>("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<Unit>("Service language not found.", ServiceErrorCode.NotFound);
if (language.VersionCount > 0)
return Failure<Unit>("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<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,5 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record EmailTemplateDefinitionDto(Guid Id, Guid ServiceId, string ServiceName, string Key, Guid Revision,
IReadOnlyList<TemplateVariableDto> Variables, IReadOnlyList<TemplateTranslationDto> Translations,
DateTimeOffset Created, DateTimeOffset? Updated);
@@ -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<GetAllNotificationServicesQuery> logger, INotificationServiceRepository services)
: RequestHandler<GetAllNotificationServicesQuery, ServiceResult<IReadOnlyList<NotificationServiceDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<NotificationServiceDto>>> DoOnHandle(GetAllNotificationServicesQuery request, CancellationToken cancellationToken)
{
var all = await services.GetAllAsync(cancellationToken);
return Success<IReadOnlyList<NotificationServiceDto>>(all.Select(NotificationServiceMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetAllNotificationServicesQuery() : IRequest<ServiceResult<IReadOnlyList<NotificationServiceDto>>>;
@@ -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<GetEmailTemplateDefinitionQuery> logger, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<GetEmailTemplateDefinitionQuery, ServiceResult<EmailTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<EmailTemplateDefinitionDto>> DoOnHandle(GetEmailTemplateDefinitionQuery request, CancellationToken cancellationToken)
{
var definition = await definitions.GetDefinitionAsync(request.ServiceId, request.Key, cancellationToken);
return definition is null
? Failure<EmailTemplateDefinitionDto>("Template definition not found.", ServiceErrorCode.NotFound)
: Success(TemplateDefinitionMapping.Map(definition));
}
}
@@ -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<ServiceResult<EmailTemplateDefinitionDto>>;
@@ -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<GetNotificationServiceQuery> logger, INotificationServiceRepository services)
: RequestHandler<GetNotificationServiceQuery, ServiceResult<NotificationServiceDto>>(logger)
{
protected override async Task<ServiceResult<NotificationServiceDto>> DoOnHandle(GetNotificationServiceQuery request, CancellationToken cancellationToken)
{
if (request.ServiceId is null && string.IsNullOrWhiteSpace(request.Name))
return Failure<NotificationServiceDto>("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<NotificationServiceDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(NotificationServiceMapping.Map(service));
}
}
@@ -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<ServiceResult<NotificationServiceDto>>;
@@ -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<GetServiceTemplateDefinitionsQuery> logger,
INotificationServiceRepository services, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<GetServiceTemplateDefinitionsQuery, ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>> DoOnHandle(GetServiceTemplateDefinitionsQuery request, CancellationToken cancellationToken)
{
if (await services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<IReadOnlyList<EmailTemplateDefinitionDto>>("Service not found.", ServiceErrorCode.NotFound);
var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken);
return Success<IReadOnlyList<EmailTemplateDefinitionDto>>(all.Select(TemplateDefinitionMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetServiceTemplateDefinitionsQuery(Guid ServiceId) : IRequest<ServiceResult<IReadOnlyList<EmailTemplateDefinitionDto>>>;
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record NotificationServiceDto(Guid Id, string Name, Guid Revision, IReadOnlyList<string> SupportedLanguageCodes, IReadOnlyList<ServiceLanguageDto> Languages);
@@ -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());
}
@@ -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<ServiceResult<NotificationServiceDto>>;
@@ -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<SaveNotificationServiceCommand> logger, INotificationServiceRepository services)
: RequestHandler<SaveNotificationServiceCommand, ServiceResult<NotificationServiceDto>>(logger)
{
protected override async Task<ServiceResult<NotificationServiceDto>> DoOnHandle(SaveNotificationServiceCommand request, CancellationToken cancellationToken)
{
if (!request.IsNew && request.Service.Revision == Guid.Empty)
return Failure<NotificationServiceDto>("A nonempty service revision is required for update.", ServiceErrorCode.InvalidRequest);
if (!request.IsNew && await services.GetAsync(request.Service.Id, cancellationToken) is null)
return Failure<NotificationServiceDto>("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<NotificationServiceDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record ServiceLanguageDto(Guid Id, string Code, int VersionCount);
@@ -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);
}
@@ -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);
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record TemplateVariableDto(string Name, bool Required);
@@ -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<ServiceResult<EmailTemplateDefinitionDto>>;
@@ -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<UpsertEmailTemplateDefinitionCommand> logger, IEmailTemplateDefinitionRepository definitions)
: RequestHandler<UpsertEmailTemplateDefinitionCommand, ServiceResult<EmailTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<EmailTemplateDefinitionDto>> DoOnHandle(UpsertEmailTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken);
return definition is null
? Failure<EmailTemplateDefinitionDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(TemplateDefinitionMapping.Map(definition));
}
catch (EmailTemplateWriteException error)
{
return Failure<EmailTemplateDefinitionDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -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<TemplateDatabaseFixture>
{
[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<string, string> { ["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<INotificationServiceRepository>();
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</a>", filtered);
Assert.DoesNotContain("Create First Template", filtered);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(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<IEmailTemplateDefinitionRepository, UnavailableEmailTemplateDefinitionRepository>()));
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, "<input[^>]+type=\"checkbox\"[^>]*>").Value);
}
@@ -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<TemplateDatabaseFixture>
{
[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</a>", 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<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().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, "<table[^>]+admin-data-table"));
Assert.Equal(Regex.Matches(html, "<th[ >]").Count, Regex.Matches(html, "<th scope=\"col\"").Count);
Assert.DoesNotContain("<thead class=\"table-dark\">", html);
}
}
}
@@ -3,30 +3,12 @@ namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminTemplatesIndexViewTests public sealed class AdminTemplatesIndexViewTests
{ {
[Fact] [Fact]
public void CreateAndEditLinks_UseExplicitFilterQueryInterpolation() public void CreateAndEditLinks_KeepTheirServiceAndKeyFilter()
{ {
string view = File.ReadAllText(FindIndexView()); string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml"));
Assert.Contains("asp-route-serviceId=\"@serviceId\"", view);
Assert.Contains("/admin/templates/create@(filterQuery)", view); Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view);
Assert.Contains("@t.LanguageCode@(filterQuery)", view); Assert.Contains("new { serviceId, definitionId = template.Id, keyFilter }", view);
Assert.DoesNotContain("create@filterQuery", view); Assert.DoesNotContain("/admin/templates", 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");
} }
} }
@@ -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<DefinitionApiTestContext> CreateAsync(TemplateDatabaseFixture fixture)
{
var app = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync());
var client = app.CreateClient();
var response = await client.GetFromJsonAsync<JsonElement>("/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<HttpResponseMessage> UpsertAsync(JsonNode body, string key = "Welcome") =>
Client.PostAsJsonAsync(DefinitionUrl(key, upsert: true), body);
public async Task<JsonElement> ReadAsync(string key = "Welcome") =>
(await Client.GetFromJsonAsync<JsonElement>(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"] = "<p>Hello {{Name}}</p>", ["textBody"] = "Hello {{Name}}"
}).ToArray())
};
public async ValueTask DisposeAsync()
{
Client.Dispose();
await App.DisposeAsync();
}
}
@@ -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<TemplateDatabaseFixture>
{
[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<INotificationServiceRepository>().GetAllAsync()).Single();
var definition = new EmailTemplateDefinition { ServiceName = service.Name, Key = "Layout" };
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().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("<dl class=\"template-service-context\">", errorHtml);
Assert.Contains("<dt>Service</dt>", errorHtml);
Assert.Contains("<dd>TestService</dd>", 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("<h1 class=\"editor-card-title\">", html);
Assert.Contains("<span>Create Channel</span>", 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("<nav aria-label=\"Breadcrumb\"", StringComparison.Ordinal);
var card = html.IndexOf("<div class=\"card editor-card\">", StringComparison.Ordinal);
Assert.True(breadcrumb >= 0 && breadcrumb < card);
Assert.Single(Regex.Matches(html, "<h1 class=\"editor-card-title\">"));
Assert.Contains("aria-hidden=\"true\"", html);
Assert.Contains("<span>" + title + "</span>", html);
Assert.Contains("id=\"" + form + "\"", html);
Assert.Contains("aria-current=\"page\"", html);
}
}
@@ -0,0 +1,92 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
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 EmailChannelApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var request = Content("smtp.example.invalid", 2525, "test-only-user");
var created = await context.Client.PostAsJsonAsync("/api/v1/email-channels", request);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var id = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid();
var original = (await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/email-channels/{id}")).GetProperty("data");
Assert.Equal("smtp.example.invalid", original.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-user", original.GetProperty("settings").GetProperty("username").GetString());
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content("updated.example.invalid", 2526, "updated-user"))).StatusCode);
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
var updated = Assert.Single(all.EnumerateArray());
Assert.Equal("updated.example.invalid", updated.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal("updated-user", updated.GetProperty("settings").GetProperty("username").GetString());
Assert.Equal(2526, updated.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-value", updated.GetProperty("settings").GetProperty("password").GetString());
Assert.False(updated.GetProperty("settings").GetProperty("useSsl").GetBoolean());
Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString());
Assert.Equal("Test sender", updated.GetProperty("settings").GetProperty("fromName").GetString());
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=IndependentChannelService");
Assert.Single(filtered.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync($"/api/v1/email-channels/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/email-channels/{id}")).StatusCode);
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
}
[Fact]
public async Task GetAllIncludesChannelsWhoseServiceNameHasNoServiceRecord()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
foreach (var serviceName in new[] { "TestService", "OrphanChannelService" })
await channels.AddAsync(new EmailChannel
{
ServiceName = serviceName, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
Assert.Equal(2, all.GetArrayLength());
Assert.Contains(all.EnumerateArray(), c => c.GetProperty("serviceName").GetString() == "OrphanChannelService");
Assert.All(all.EnumerateArray(), c => Assert.NotEqual(Guid.Empty, c.GetProperty("id").GetGuid()));
}
[Theory]
[InlineData("nullSettings")]
[InlineData("invalidPort")]
[InlineData("blankHost")]
[InlineData("unsupportedType")]
[InlineData("blankService")]
public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = JsonSerializer.SerializeToNode(Content("smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
switch (scenario)
{
case "nullSettings": body["settings"] = null; break;
case "invalidPort": body["settings"]!["port"] = 0; break;
case "blankHost": body["settings"]!["host"] = " "; break;
case "unsupportedType": body["channelType"] = 99; break;
case "blankService": body["serviceName"] = " "; break;
}
var response = await context.Client.PostAsJsonAsync("/api/v1/email-channels", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
}
private static object Content(string host, int port, string username) => new
{
serviceName = "IndependentChannelService", priority = 1, channelType = 1,
settings = new { host, port, username, password = "test-only-value", useSsl = false, fromEmail = "sender@example.invalid", fromName = "Test sender" },
dailyLimit = 100, monthlyLimit = 1000, warnThresholdPercent = 90, isActive = true
};
}
@@ -0,0 +1,212 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.TestHost;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailTemplateApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task LegacyApi_CreatesReadsUpdatesAndDeletesLanguageVersions_WithoutContractDivergence()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
var created = await client.PostAsJsonAsync("/api/v1/email-templates", Content("en"));
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var originalId = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid();
Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await client.PostAsJsonAsync("/api/v1/email-templates", Content("de", "Other"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", Content("en", "Other"))).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/uk", Content("uk", subject: "Updated uk"))).StatusCode);
var all = await client.GetFromJsonAsync<JsonElement>("/api/v1/email-templates?serviceName=TestService&key=Welcome");
Assert.Equal(2, all.GetProperty("data").GetArrayLength());
var en = await ReadAsync(client, "en");
Assert.Equal(originalId, en.GetProperty("id").GetGuid());
Assert.Equal("Subject en", en.GetProperty("subject").GetString());
Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync("/api/v1/email-templates/TestService/Welcome/uk")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync("/api/v1/email-templates/TestService/Welcome/en")).StatusCode);
Assert.Contains("data-state=\"missing\"", await client.GetStringAsync("/admin/templates?serviceName=TestService"));
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/v1/email-templates/TestService/Welcome/de")).StatusCode);
}
[Fact]
public async Task ConcurrentLegacyCreates_OnlyPersistOneLanguage()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
await client.PostAsJsonAsync("/api/v1/email-templates", Content("en"));
var responses = await Task.WhenAll(
client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")),
client.PostAsJsonAsync("/api/v1/email-templates", Content("uk")));
Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.Created));
Assert.Equal(1, responses.Count(r => r.StatusCode == HttpStatusCode.Conflict));
}
[Fact]
public async Task GroupEditor_SharedContractIsSavedForAllLanguages_AndPreservesContentIdsAndFilters()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new() { AllowAutoRedirect = false });
await client.PostAsJsonAsync("/api/v1/email-templates", Content("en"));
await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"));
var originalId = (await ReadAsync(client, "uk")).GetProperty("id").GetGuid();
var fields = await EditorFieldsAsync(client);
fields["VariablesJson"] = """[{"name":"Shared","required":true}]""";
fields["KeyFilter"] = "Welcome";
fields["Languages[0].Subject"] = "Edited English";
var response = await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal($"/admin/services/{fields["ServiceId"]}?key=Welcome", response.Headers.Location!.OriginalString);
var uk = await ReadAsync(client, "uk");
Assert.Equal(originalId, uk.GetProperty("id").GetGuid());
Assert.Equal("Subject uk", uk.GetProperty("subject").GetString());
Assert.Equal("Shared", uk.GetProperty("variables")[0].GetProperty("name").GetString());
Assert.Equal("Edited English", (await ReadAsync(client, "en")).GetProperty("subject").GetString());
var html = await client.GetStringAsync($"/admin/services/{fields["ServiceId"]}?key=Welcome");
Assert.Single(Regex.Matches(html, "<td>Welcome</td>"));
Assert.Contains("data-language-code=\"en\" data-state=\"present\"", html);
Assert.Contains("data-language-code=\"uk\" data-state=\"present\"", html);
Assert.Contains("keyFilter=Welcome", html);
Assert.DoesNotContain("@filterQuery", html);
}
[Theory]
[InlineData("{broken", "Invalid variables JSON")]
[InlineData("null", "Variables must be a JSON array")]
[InlineData("[{\"name\":\"Name\",\"required\":true},{\"name\":\"Name\",\"required\":false}]", "Variable names must be unique")]
public async Task InvalidSharedContract_KeepsEnteredValuesAndPersistsNothing(string json, string error)
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
await client.PostAsJsonAsync("/api/v1/email-templates", Content("en"));
await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"));
var fields = await EditorFieldsAsync(client);
fields["VariablesJson"] = json;
fields["Languages[1].Subject"] = "Unsaved Ukrainian";
fields["ActiveLanguageIndex"] = "1";
var response = await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields));
var html = await response.Content.ReadAsStringAsync();
Assert.Contains(error, html);
Assert.Contains("Unsaved Ukrainian", html);
Assert.Contains("name=\"ActiveLanguageIndex\" value=\"1\"", html);
Assert.Equal("Subject uk", (await ReadAsync(client, "uk")).GetProperty("subject").GetString());
}
[Fact]
public async Task DuplicateLanguageIncompleteContentAndStaleSave_AreRejectedAtomically()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
await client.PostAsJsonAsync("/api/v1/email-templates", Content("en"));
await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"));
var fields = await EditorFieldsAsync(client);
fields["Languages[1].LanguageCode"] = "en";
Assert.Contains("Language codes must be unique", await PostEditorAsync(client, fields));
fields["Languages[1].LanguageCode"] = "uk";
fields["Languages[1].TextBody"] = "";
Assert.Contains("TextBody field is required", await PostEditorAsync(client, fields));
fields["Languages[1].TextBody"] = "Restored text";
await client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", Content("en", subject: "Concurrent change"));
Assert.Contains("changed after you opened it", await PostEditorAsync(client, fields));
Assert.Equal("Concurrent change", (await ReadAsync(client, "en")).GetProperty("subject").GetString());
Assert.Equal("Subject uk", (await ReadAsync(client, "uk")).GetProperty("subject").GetString());
}
[Fact]
public async Task GroupEditor_RemovesOneLanguageAndAddsAnother_WithoutChangingRemainingLanguage()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new() { AllowAutoRedirect = false });
await client.PostAsJsonAsync("/api/v1/email-templates", Content("en"));
await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"));
var fields = await EditorFieldsAsync(client);
fields["Languages[1].Subject"] = "";
fields["Languages[1].HtmlBody"] = "";
fields["Languages[1].TextBody"] = "";
AddLanguageFields(fields, 2, "de");
fields["Languages[2].Subject"] = "German";
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields))).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/api/v1/email-templates/TestService/Welcome/uk")).StatusCode);
Assert.Equal("Subject en", (await ReadAsync(client, "en")).GetProperty("subject").GetString());
Assert.Equal("German", (await ReadAsync(client, "de")).GetProperty("subject").GetString());
}
[Fact]
public async Task FailedSave_ShowsActionableErrorAndKeepsEnteredValues()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var failingApp = app.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
services.AddScoped<IEmailTemplateDefinitionRepository, FailingEmailTemplateDefinitionRepository>()));
using var client = failingApp.CreateClient();
await client.PostAsJsonAsync("/api/v1/email-templates", Content("en"));
await client.PostAsJsonAsync("/api/v1/email-templates", Content("uk"));
var fields = await EditorFieldsAsync(client);
fields["Languages[1].Subject"] = "Unsaved after failure";
var html = await PostEditorAsync(client, fields);
Assert.Contains("Could not save the template", html);
Assert.Contains("Unsaved after failure", html);
Assert.Equal("Subject uk", (await ReadAsync(client, "uk")).GetProperty("subject").GetString());
}
private static object Content(string language, string variable = "Name", string? subject = null) => new
{
serviceName = "TestService", key = "Welcome", languageCode = language,
subject = subject ?? "Subject " + language, htmlBody = "<p>Hello {{Name}}</p>", textBody = "Hello {{Name}}",
variables = new[] { new { name = variable, required = true } }
};
private static async Task<JsonElement> ReadAsync(HttpClient client, string language) =>
(await client.GetFromJsonAsync<JsonElement>("/api/v1/email-templates/TestService/Welcome/" + language)).GetProperty("data");
private static async Task<Dictionary<string, string>> EditorFieldsAsync(HttpClient client)
{
var response = await client.GetAsync("/admin/templates/TestService/Welcome/uk");
var html = response.Headers.Location is Uri location ? await client.GetStringAsync(location) : await response.Content.ReadAsStringAsync();
var fields = new Dictionary<string, string>();
foreach (Match input in Regex.Matches(html, "<input[^>]+>"))
{
var name = Regex.Match(input.Value, "name=\"([^\"]+)\"");
var value = Regex.Match(input.Value, "value=\"([^\"]*)\"");
if (name.Success && value.Success)
fields[WebUtility.HtmlDecode(name.Groups[1].Value)] = WebUtility.HtmlDecode(value.Groups[1].Value);
}
foreach (var codeField in fields.Keys.Where(k => k.EndsWith(".LanguageCode")).ToList())
{
var prefix = codeField[..^"LanguageCode".Length];
fields.TryAdd(prefix + "Subject", "");
fields.TryAdd(prefix + "HtmlBody", "");
fields.TryAdd(prefix + "TextBody", "");
}
fields["ServiceName"] = "TestService";
fields["Key"] = "Welcome";
fields["VariablesJson"] = """[{"name":"Name","required":true}]""";
AddLanguageFields(fields, 0, "en");
AddLanguageFields(fields, 1, "uk");
return fields;
}
private static void AddLanguageFields(Dictionary<string, string> fields, int index, string language)
{
fields[$"Languages[{index}].LanguageCode"] = language;
fields[$"Languages[{index}].Subject"] = "Subject " + language;
fields[$"Languages[{index}].HtmlBody"] = "<p>Hello {{Name}}</p>";
fields[$"Languages[{index}].TextBody"] = "Hello {{Name}}";
}
private static string SaveUrl(Dictionary<string, string> fields) => $"/admin/services/{fields["ServiceId"]}/templates/save";
private static async Task<string> PostEditorAsync(HttpClient client, Dictionary<string, string> fields) =>
await (await client.PostAsync(SaveUrl(fields), new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
}
@@ -0,0 +1,228 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailTemplateDefinitionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ServiceReadsExposeExactCatalogAndStableIdentityThroughRename()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
Assert.Equal(context.ServiceId, service.GetProperty("id").GetGuid());
Assert.Equal("TestService", service.GetProperty("name").GetString());
Assert.Equal(new[] { "de", "en", "ru", "ru-RU", "uk" }, service.GetProperty("supportedLanguageCodes").EnumerateArray().Select(c => c.GetString()));
Assert.Equal(5, service.EnumerateObject().Count());
Assert.NotEqual(Guid.Empty, service.GetProperty("revision").GetGuid());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=testservice")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync("/api/v1/services")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}")).StatusCode);
using var scope = context.App.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var model = (await repository.GetAsync(context.ServiceId))!;
model.Name = "Renamed Service";
await repository.SaveAsync(model, false);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=TestService")).StatusCode);
var renamed = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services?name=Renamed%20Service");
Assert.Equal(context.ServiceId, renamed.GetProperty("data").GetProperty("id").GetGuid());
Assert.Equal("Renamed Service", (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data").GetProperty("name").GetString());
}
[Fact]
public async Task AtomicUpsertReplacesContractAndSentContent_AndRetainsOmittedTranslations()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"))).StatusCode);
var original = await context.ReadAsync();
var uk = original.GetProperty("translations")[1];
Assert.True(uk.GetProperty("created").GetDateTimeOffset().Year >= 2020);
var revision = original.GetProperty("revision").GetGuid();
var update = DefinitionApiTestContext.Body(revision, "en", "de");
update["variables"] = new JsonArray(new JsonObject { ["name"] = "Changed", ["required"] = false });
update["translations"]![0]!["subject"] = "Edited English";
var response = await context.UpsertAsync(update);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var returned = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
var saved = await context.ReadAsync();
Assert.Equal(saved.GetProperty("revision").GetGuid(), returned.GetProperty("revision").GetGuid());
Assert.NotEqual(revision, saved.GetProperty("revision").GetGuid());
Assert.Equal(original.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid());
Assert.Equal(3, saved.GetProperty("translations").GetArrayLength());
Assert.Equal(uk.GetRawText(), saved.GetProperty("translations")[2].GetRawText());
Assert.Equal("Edited English", saved.GetProperty("translations")[1].GetProperty("subject").GetString());
Assert.Equal(original.GetProperty("translations")[0].GetProperty("id").GetGuid(), saved.GetProperty("translations")[1].GetProperty("id").GetGuid());
var legacy = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-templates/TestService/Welcome/uk");
Assert.Equal("Changed", legacy.GetProperty("data").GetProperty("variables")[0].GetProperty("name").GetString());
Assert.False(saved.GetProperty("variables")[0].GetProperty("required").GetBoolean());
}
[Fact]
public async Task ExplicitRemovalCanRemoveLastVersion_AndKeepDefinitionAndContract()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var before = await context.ReadAsync();
var request = DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid());
request["removeLanguageCodes"] = new JsonArray("en", "uk", "de");
var response = await context.UpsertAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var returned = (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Assert.Empty(returned.GetProperty("translations").EnumerateArray());
var saved = await context.ReadAsync();
Assert.Equal(before.GetProperty("id").GetGuid(), saved.GetProperty("id").GetGuid());
Assert.Empty(saved.GetProperty("translations").EnumerateArray());
Assert.Single(saved.GetProperty("variables").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/email-templates/TestService/Welcome/en")).StatusCode);
}
[Fact]
public async Task RevisionsEnforceCreateOnlyAndUpdateOnly_WithoutPartialChanges()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var before = await context.ReadAsync();
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.NewGuid(), "en"), "Missing")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl("Missing"))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(DefinitionApiTestContext.Body(Guid.Empty))).StatusCode);
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
var revision = before.GetProperty("revision").GetGuid();
Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision))).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk"))).StatusCode);
var otherRoot = $"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/upsert?key=Welcome";
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.PostAsJsonAsync(otherRoot, DefinitionApiTestContext.Body(null))).StatusCode);
}
[Theory]
[InlineData("missingVariables")]
[InlineData("nullVariables")]
[InlineData("missingTranslations")]
[InlineData("nullTranslations")]
[InlineData("nullVariable")]
[InlineData("nullTranslation")]
[InlineData("missingRequired")]
[InlineData("nonBooleanRequired")]
[InlineData("duplicateVariable")]
[InlineData("blankVariable")]
[InlineData("duplicateLanguage")]
[InlineData("unknownLanguage")]
[InlineData("blankContent")]
[InlineData("missingContent")]
[InlineData("duplicateRemoval")]
[InlineData("unknownRemoval")]
[InlineData("nullRemoval")]
[InlineData("overlap")]
public async Task InvalidUpdateDoesNotChangeContractTranslationsOrRevision(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"));
var before = await context.ReadAsync();
var request = InvalidDefinitionApiBody.Create(scenario, before.GetProperty("revision").GetGuid());
var response = await context.UpsertAsync(request);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var error = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.False(error.GetProperty("success").GetBoolean());
Assert.Equal("InvalidRequest", error.GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task ConcurrentCreatesAndUpdatesHaveOneWinnerAndNoLostMerge()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var created = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(null, "en")), context.UpsertAsync(DefinitionApiTestContext.Body(null, "uk")));
Assert.Equal(new[] { HttpStatusCode.Created, HttpStatusCode.Conflict }, created.Select(r => r.StatusCode).Order().ToArray());
var before = await context.ReadAsync();
var revision = before.GetProperty("revision").GetGuid();
var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "de")), context.UpsertAsync(DefinitionApiTestContext.Body(revision, "ru")));
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray());
var after = await context.ReadAsync();
Assert.Equal(2, after.GetProperty("translations").GetArrayLength());
var retained = before.GetProperty("translations")[0];
Assert.Contains(after.GetProperty("translations").EnumerateArray(), item => item.GetProperty("id").GetGuid() == retained.GetProperty("id").GetGuid());
}
[Fact]
public async Task RejectedCreateAndMalformedRequestsLeaveNoDefinition()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var definitions = context.ServiceRoot + "/email-template-definitions";
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(definitions + "/upsert", DefinitionApiTestContext.Body(null))).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.GetAsync(definitions + "?key=%20")).StatusCode);
var invalid = DefinitionApiTestContext.Body(null, "en", "unknown");
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalid)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode);
using var nullBody = new StringContent("null", System.Text.Encoding.UTF8, "application/json");
var response = await context.Client.PostAsync(context.DefinitionUrl(upsert: true), nullBody);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
var invalidRevision = DefinitionApiTestContext.Body(null);
invalidRevision["revision"] = "not-a-guid";
Assert.Equal(HttpStatusCode.BadRequest, (await context.UpsertAsync(invalidRevision)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl())).StatusCode);
}
[Fact]
public async Task ExactCaseCatalogCodesCanCoexistAndBeRemovedIndependently()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Languages = service.Languages.Append(new() { Code = "RU" }).ToList();
await services.SaveAsync(service, false);
Assert.Equal(HttpStatusCode.Created, (await context.UpsertAsync(DefinitionApiTestContext.Body(null, "RU", "ru", "ru-RU"))).StatusCode);
var saved = await context.ReadAsync();
var removal = DefinitionApiTestContext.Body(saved.GetProperty("revision").GetGuid());
removal["variables"] = new JsonArray();
removal["removeLanguageCodes"] = new JsonArray("RU");
Assert.Equal(HttpStatusCode.OK, (await context.UpsertAsync(removal)).StatusCode);
var after = await context.ReadAsync();
Assert.Empty(after.GetProperty("variables").EnumerateArray());
Assert.Equal(new[] { "ru", "ru-RU" }, after.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString()));
}
[Fact]
public async Task LegacyContentWriteInvalidatesLastReadRevision()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await context.ReadAsync();
var body = DefinitionApiTestContext.Body(null, "en");
var legacy = (JsonObject)body["translations"]![0]!.DeepClone();
legacy["subject"] = "Legacy edit";
legacy["variables"] = body["variables"]!.DeepClone();
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync("/api/v1/email-templates/TestService/Welcome/en", legacy)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.UpsertAsync(DefinitionApiTestContext.Body(before.GetProperty("revision").GetGuid(), "uk"))).StatusCode);
var after = await context.ReadAsync();
Assert.Single(after.GetProperty("translations").EnumerateArray());
Assert.Equal("Legacy edit", after.GetProperty("translations")[0].GetProperty("subject").GetString());
}
[Theory]
[InlineData("Welcome + % # ? Привіт")]
[InlineData("Welcome%2Fexact")]
[InlineData("Welcome/child")]
public async Task EncodedKeysRoundTripExactly(string key)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var created = await context.UpsertAsync(DefinitionApiTestContext.Body(null, "ru", "ru-RU"), key);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
Assert.NotNull(created.Headers.Location);
var located = await context.Client.GetFromJsonAsync<JsonElement>(created.Headers.Location);
Assert.Equal(key, located.GetProperty("data").GetProperty("key").GetString());
Assert.Equal(context.ServiceId, located.GetProperty("data").GetProperty("serviceId").GetGuid());
var read = await context.ReadAsync(key);
Assert.Equal(read.GetProperty("id").GetGuid(), located.GetProperty("data").GetProperty("id").GetGuid());
Assert.Equal(key, read.GetProperty("key").GetString());
Assert.Equal(new[] { "ru", "ru-RU" }, read.GetProperty("translations").EnumerateArray().Select(t => t.GetProperty("languageCode").GetString()));
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key.ToLowerInvariant()))).StatusCode);
}
}
@@ -0,0 +1,106 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailTemplateMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task EquivalentContracts_PreserveLanguageIdsContentAndSharedVariables_AndRerunSafely()
{
var connection = await fixture.CreateDatabaseAsync(legacy: true);
var en = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", """[{"name":"Name","required":true},{"name":"Link","required":false}]""");
var uk = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "UK", """[{"required":false,"name":"Link"},{"required":true,"name":"Name"}]""");
await MigrateAsync(connection);
await MigrateAsync(connection);
using var services = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
var templates = await repository.GetAllAsync("TestService", "Welcome");
Assert.Equal(2, templates.Count);
Assert.Contains(templates, t => t.Id == en && t.Subject == "Subject en" && t.HtmlBody == "<p>{{Name}}</p>");
Assert.Contains(templates, t => t.Id == uk && t.LanguageCode == "UK" && t.Subject == "Subject UK");
Assert.All(templates, t => Assert.Equal(2, t.Variables.Count));
await using var db = TemplateDatabaseFixture.CreateContext(connection);
await db.GetService<IMigrator>().MigrateAsync(TemplateDatabaseFixture.LegacyMigration);
await db.Database.MigrateAsync();
Assert.Equal(en, (await repository.GetAsync("TestService", "Welcome", "en"))!.Id);
}
[Theory]
[InlineData("[{\"name\":\"Other\",\"required\":true}]", "Conflicting shared variables")]
[InlineData("{}", "Invalid template identity or variables array")]
[InlineData("[null]", "Invalid variables shape")]
[InlineData("[{\"name\":\"Name\",\"required\":\"yes\"}]", "Invalid variables shape")]
[InlineData("[{\"name\":\"Name\",\"required\":true},{\"name\":\"Name\",\"required\":true}]", "Duplicate variable names")]
public async Task InvalidLegacyData_StopsWithoutChangingSource_ThenCanRecover(string variables, string diagnostic)
{
var connection = await fixture.CreateDatabaseAsync(legacy: true);
await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", """[{"name":"Name","required":true}]""");
await TemplateDatabaseFixture.InsertLegacyAsync(connection, "uk", variables);
var error = await Assert.ThrowsAsync<PostgresException>(() => MigrateAsync(connection));
Assert.Contains(diagnostic, error.MessageText);
Assert.Contains("TestService", error.MessageText);
Assert.DoesNotContain("Hello", error.MessageText);
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF to_regclass('public.services') IS NOT NULL OR (SELECT count(*) FROM email_templates) <> 2 THEN
RAISE EXCEPTION 'Migration partially changed source';
END IF;
END $$;
UPDATE email_templates SET variables = '[{"name":"Name","required":true}]'::jsonb;
""");
await MigrateAsync(connection);
}
[Fact]
public async Task ExactLanguageCodes_RemainDistinctAfterMigration()
{
var connection = await fixture.CreateDatabaseAsync(legacy: true);
await TemplateDatabaseFixture.InsertLegacyAsync(connection, "ru", "[]");
await TemplateDatabaseFixture.InsertLegacyAsync(connection, "ru-RU", "[]");
await TemplateDatabaseFixture.InsertLegacyAsync(connection, "EN", "[]");
await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", "[]");
await MigrateAsync(connection);
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF (SELECT count(*) FROM service_languages) <> 4
OR NOT EXISTS (SELECT 1 FROM service_languages WHERE code = 'ru-RU')
OR NOT EXISTS (SELECT 1 FROM service_languages WHERE code = 'EN') THEN
RAISE EXCEPTION 'Migration changed exact language codes';
END IF;
END $$;
""");
}
[Fact]
public async Task InterruptedConversion_RollsBackAndCanBeRetried()
{
var connectionString = await fixture.CreateDatabaseAsync(legacy: true);
await TemplateDatabaseFixture.InsertLegacyAsync(connectionString, "en", "[]");
await using var db = TemplateDatabaseFixture.CreateContext(connectionString);
var script = db.GetService<IMigrator>().GenerateScript(TemplateDatabaseFixture.LegacyMigration, options: MigrationsSqlGenerationOptions.NoTransactions);
script = script.Replace("ALTER TABLE email_templates DROP COLUMN", "PERFORM pg_sleep(2); ALTER TABLE email_templates DROP COLUMN");
await using (var connection = new NpgsqlConnection(connectionString))
{
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
await using var command = new NpgsqlCommand("SET LOCAL statement_timeout = '500ms'; " + script, connection, transaction);
var error = await Assert.ThrowsAsync<PostgresException>(() => command.ExecuteNonQueryAsync());
Assert.Equal("57014", error.SqlState);
await transaction.RollbackAsync();
}
await MigrateAsync(connectionString);
}
private static async Task MigrateAsync(string connectionString)
{
await using var db = TemplateDatabaseFixture.CreateContext(connectionString);
await db.Database.MigrateAsync();
}
}
@@ -0,0 +1,28 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class FailingEmailTemplateDefinitionRepository(IEmailTemplateRepository inner) : IEmailTemplateDefinitionRepository
{
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).GetDefinitionsAsync(serviceName, key, ct);
public Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceName, key, ct);
public Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).GetDefinitionAsync(serviceId, key, ct);
public Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) =>
throw new IOException("Simulated save failure.");
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).GetDefinitionsAsync(serviceId, ct);
public Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) =>
((IEmailTemplateDefinitionRepository)inner).DeleteDefinitionAsync(serviceId, key, revision, ct);
public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) =>
throw new IOException("Simulated save failure.");
}
@@ -10,16 +10,22 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="SSH.NET" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Using Include="Xunit" /> <Using Include="Xunit" />
<None Include="..\HrynCo.NotificationService.Web\Views\AdminTemplates\Index.cshtml"
Link="TestViews\Index.cshtml" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HrynCo.NotificationService.Web\HrynCo.NotificationService.Web.csproj" /> <ProjectReference Include="..\HrynCo.NotificationService.Web\HrynCo.NotificationService.Web.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,41 @@
namespace HrynCo.NotificationService.Web.IntegrationTests;
public static class InterimEmailTemplateFixture
{
public const string MigrationId = "20261017070315_EmailTemplateLocalizations";
public static async Task<string> CreateAsync(TemplateDatabaseFixture fixture)
{
var connection = await fixture.CreateDatabaseAsync(legacy: true);
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DROP INDEX "IX_email_templates_service_name_key_language_code";
ALTER TABLE email_templates DROP COLUMN service_name, DROP COLUMN key, DROP COLUMN variables;
ALTER TABLE email_templates ADD COLUMN definition_id uuid NOT NULL;
CREATE TABLE services (id uuid PRIMARY KEY, name varchar(100) NOT NULL, created timestamptz NOT NULL, updated timestamptz);
CREATE UNIQUE INDEX "IX_services_name" ON services(name);
CREATE TABLE email_template_definitions (
id uuid PRIMARY KEY, service_id uuid NOT NULL REFERENCES services(id) ON DELETE RESTRICT,
key varchar(100) NOT NULL, revision uuid NOT NULL, variables jsonb NOT NULL,
created timestamptz NOT NULL, updated timestamptz);
CREATE UNIQUE INDEX "IX_email_template_definitions_service_id_key" ON email_template_definitions(service_id, key);
ALTER TABLE email_templates ADD CONSTRAINT "FK_email_templates_email_template_definitions_definition_id"
FOREIGN KEY(definition_id) REFERENCES email_template_definitions(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX "IX_email_templates_definition_id_language_code" ON email_templates(definition_id, language_code);
INSERT INTO "__EFMigrationsHistory" VALUES ('20261017070315_EmailTemplateLocalizations', '10.0.7');
INSERT INTO services VALUES
('11111111-1111-1111-1111-111111111111', 'InterimService', '2026-01-01Z', '2026-02-01Z'),
('88888888-8888-8888-8888-888888888888', 'UnusedService', '2026-01-02Z', null);
INSERT INTO email_template_definitions VALUES
('22222222-2222-2222-2222-222222222222', '11111111-1111-1111-1111-111111111111', 'Welcome',
'44444444-4444-4444-4444-444444444444', '[{"name":"Name","required":true}]', '2026-01-03Z', '2026-02-03Z'),
('33333333-3333-3333-3333-333333333333', '11111111-1111-1111-1111-111111111111', 'Empty',
'55555555-5555-5555-5555-555555555555', '[]', '2026-01-04Z', null);
INSERT INTO email_templates(id, definition_id, language_code, subject, html_body, text_body, created, updated) VALUES
('66666666-6666-6666-6666-666666666666', '22222222-2222-2222-2222-222222222222', 'ru-RU',
'Hello {{Name}}', '<p>{{Name}}</p>', 'Hello {{Name}}', '2026-01-05Z', '2026-02-05Z'),
('77777777-7777-7777-7777-777777777777', '22222222-2222-2222-2222-222222222222', 'ru',
'Welcome {{Name}}', '<p>Welcome {{Name}}</p>', 'Welcome {{Name}}', '2026-01-06Z', null);
""");
return connection;
}
}
@@ -0,0 +1,131 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class InterimEmailTemplateMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task KnownInterimLayout_UpgradesInPlacePreservingEveryExistingFieldAndHistory()
{
var connection = await InterimEmailTemplateFixture.CreateAsync(fixture);
var original = await SourceSnapshotAsync(connection, upgraded: false);
await MigrateAsync(connection);
Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: true));
var firstServices = await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s");
await MigrateAsync(connection);
Assert.Equal(firstServices, await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s"));
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var history = (await db.Database.GetAppliedMigrationsAsync()).ToList();
Assert.Equal(4, history.Count);
Assert.Contains(InterimEmailTemplateFixture.MigrationId, history);
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
Assert.Equal(Guid.Parse("66666666-6666-6666-6666-666666666666"), (await repository.GetAsync("InterimService", "Welcome", "ru-RU"))!.Id);
Assert.Equal(Guid.Parse("77777777-7777-7777-7777-777777777777"), (await repository.GetAsync("InterimService", "Welcome", "ru"))!.Id);
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
Assert.Empty((await definitions.GetDefinitionAsync("InterimService", "Empty"))!.Languages);
var services = await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetAllAsync();
Assert.Contains(services, s => s.Name == "UnusedService" && s.Languages.Count == 0);
Assert.All(services, s => Assert.NotEqual(Guid.Empty, s.Revision));
}
[Theory]
[InlineData("ALTER TABLE email_templates ADD COLUMN service_name varchar(100)")]
[InlineData("ALTER TABLE email_templates ALTER COLUMN language_code TYPE varchar(11)")]
[InlineData("DROP INDEX \"IX_email_templates_definition_id_language_code\"")]
[InlineData("DROP INDEX \"IX_email_templates_definition_id_language_code\"; CREATE UNIQUE INDEX \"IX_email_templates_definition_id_language_code\" ON email_templates(id)")]
[InlineData("ALTER TABLE email_templates DROP CONSTRAINT \"FK_email_templates_email_template_definitions_definition_id\"; ALTER TABLE email_templates ADD CONSTRAINT \"FK_email_templates_email_template_definitions_definition_id\" FOREIGN KEY(definition_id) REFERENCES email_template_definitions(id) ON DELETE RESTRICT")]
[InlineData("DELETE FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20261017070315_EmailTemplateLocalizations'")]
[InlineData("CREATE TABLE service_languages(id uuid)")]
public async Task HybridOrUnknownLayout_FailsWithoutAdoptingOrChangingIt(string change)
{
var connection = await InterimEmailTemplateFixture.CreateAsync(fixture);
await TemplateDatabaseFixture.ExecuteAsync(connection, change);
var original = await SourceSnapshotAsync(connection, upgraded: false);
var error = await Assert.ThrowsAsync<PostgresException>(() => MigrateAsync(connection));
Assert.Contains("Unsupported email-template source layout", error.MessageText);
Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: false));
}
[Theory]
[InlineData("UPDATE email_template_definitions SET variables = '{}'", "Invalid interim shared variables array")]
[InlineData("UPDATE email_template_definitions SET variables = '[null]'", "Invalid interim shared variables shape")]
[InlineData("UPDATE email_templates SET language_code = '' WHERE id = '66666666-6666-6666-6666-666666666666'", "Invalid blank identity")]
public async Task InvalidInterimData_FailsBeforeSchemaChanges(string change, string diagnostic)
{
var connection = await InterimEmailTemplateFixture.CreateAsync(fixture);
await TemplateDatabaseFixture.ExecuteAsync(connection, change);
var original = await SourceSnapshotAsync(connection, upgraded: false);
var error = await Assert.ThrowsAsync<PostgresException>(() => MigrateAsync(connection));
Assert.Contains(diagnostic, error.MessageText);
Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: false));
Assert.Equal("0", await ScalarAsync(connection, "SELECT count(*)::text FROM information_schema.columns WHERE table_name = 'services' AND column_name = 'revision'"));
}
[Fact]
public async Task InterruptedInterimUpgrade_RollsBackAndCanBeRetried()
{
var connectionString = await InterimEmailTemplateFixture.CreateAsync(fixture);
var original = await SourceSnapshotAsync(connectionString, upgraded: false);
await using var db = TemplateDatabaseFixture.CreateContext(connectionString);
var script = db.GetService<IMigrator>().GenerateScript(TemplateDatabaseFixture.LegacyMigration, options: MigrationsSqlGenerationOptions.NoTransactions);
script = script.Replace("ALTER TABLE email_templates DROP COLUMN language_code;", "PERFORM pg_sleep(2); ALTER TABLE email_templates DROP COLUMN language_code;");
await using (var connection = new NpgsqlConnection(connectionString))
{
await connection.OpenAsync();
await using var transaction = await connection.BeginTransactionAsync();
await using var command = new NpgsqlCommand("SET LOCAL statement_timeout = '500ms'; " + script, connection, transaction);
Assert.Equal("57014", (await Assert.ThrowsAsync<PostgresException>(() => command.ExecuteNonQueryAsync())).SqlState);
await transaction.RollbackAsync();
}
Assert.Equal(original, await SourceSnapshotAsync(connectionString, upgraded: false));
await MigrateAsync(connectionString);
Assert.Equal(original, await SourceSnapshotAsync(connectionString, upgraded: true));
}
[Fact]
public async Task InterimOrigin_DownIsBlockedBeforeAnyChanges()
{
var connection = await InterimEmailTemplateFixture.CreateAsync(fixture);
await MigrateAsync(connection);
var original = await SourceSnapshotAsync(connection, upgraded: true);
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var error = await Assert.ThrowsAsync<PostgresException>(() => db.GetService<IMigrator>().MigrateAsync(TemplateDatabaseFixture.LegacyMigration));
Assert.Contains("Cannot downgrade an interim-origin database", error.MessageText);
Assert.Equal(original, await SourceSnapshotAsync(connection, upgraded: true));
}
private static async Task MigrateAsync(string connection)
{
await using var db = TemplateDatabaseFixture.CreateContext(connection);
await db.Database.MigrateAsync();
}
private static Task<string> SourceSnapshotAsync(string connection, bool upgraded)
{
var versions = upgraded
? "SELECT (to_jsonb(t) - 'service_id' - 'service_language_id') || jsonb_build_object('language_code', l.code) AS value FROM email_templates t JOIN service_languages l ON l.id = t.service_language_id"
: "SELECT to_jsonb(t) AS value FROM email_templates t";
return ScalarAsync(connection, $"""
SELECT jsonb_build_object(
'services', (SELECT jsonb_agg(to_jsonb(s) - 'revision' ORDER BY id) FROM services s),
'definitions', (SELECT jsonb_agg(to_jsonb(d) ORDER BY id) FROM email_template_definitions d),
'versions', (SELECT jsonb_agg(value ORDER BY value->>'id') FROM ({versions}) q))::text
""");
}
private static async Task<string> ScalarAsync(string connectionString, string sql)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand(sql, connection);
return (string)(await command.ExecuteScalarAsync())!;
}
}
@@ -0,0 +1,47 @@
using System.Text.Json.Nodes;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public static class InvalidDefinitionApiBody
{
public static JsonObject Create(string scenario, Guid revision)
{
var body = DefinitionApiTestContext.Body(revision, "en");
body["removeLanguageCodes"] = new JsonArray("uk");
ApplyVariableScenario(scenario, body, (JsonArray)body["variables"]!);
ApplyTranslationScenario(scenario, body, (JsonArray)body["translations"]!);
return body;
}
private static void ApplyVariableScenario(string scenario, JsonObject body, JsonArray variables)
{
switch (scenario)
{
case "missingVariables": body.Remove("variables"); break;
case "nullVariables": body["variables"] = null; break;
case "nullVariable": variables.Add((JsonNode?)null); break;
case "missingRequired": ((JsonObject)variables[0]!).Remove("required"); break;
case "nonBooleanRequired": variables[0]!["required"] = "true"; break;
case "duplicateVariable": variables.Add(variables[0]!.DeepClone()); break;
case "blankVariable": variables[0]!["name"] = " "; break;
}
}
private static void ApplyTranslationScenario(string scenario, JsonObject body, JsonArray translations)
{
switch (scenario)
{
case "missingTranslations": body.Remove("translations"); break;
case "nullTranslations": body["translations"] = null; break;
case "nullTranslation": translations.Add((JsonNode?)null); break;
case "duplicateLanguage": translations.Add(translations[0]!.DeepClone()); break;
case "unknownLanguage": translations[0]!["languageCode"] = "RU"; break;
case "blankContent": translations[0]!["textBody"] = " "; break;
case "missingContent": ((JsonObject)translations[0]!).Remove("htmlBody"); break;
case "duplicateRemoval": body["removeLanguageCodes"] = new JsonArray("uk", "uk"); break;
case "unknownRemoval": body["removeLanguageCodes"] = new JsonArray("UK"); break;
case "nullRemoval": body["removeLanguageCodes"] = new JsonArray((JsonNode?)null); break;
case "overlap": body["removeLanguageCodes"] = new JsonArray("en"); break;
}
}
}
@@ -0,0 +1,143 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ServiceCatalogApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task CreateUpdateAndExactReadRoundTripLanguageIdentitiesAndRevision()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = new { name = "Api Created", languages = new[] { new { code = "en" }, new { code = "uk" } } };
var response = await context.Client.PostAsJsonAsync("/api/v1/services", body);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
Assert.NotNull(response.Headers.Location);
var service = (await context.Client.GetFromJsonAsync<JsonElement>(response.Headers.Location)).GetProperty("data");
Assert.Equal(2, service.GetProperty("languages").GetArrayLength());
Assert.All(service.GetProperty("languages").EnumerateArray(), l => Assert.NotEqual(Guid.Empty, l.GetProperty("id").GetGuid()));
var url = "/api/v1/services/" + service.GetProperty("id").GetGuid();
var update = UpdateBody(service);
update["name"] = "Renamed API";
update["languages"]![0]!["code"] = "en-GB";
((JsonArray)update["languages"]!).Add(new JsonObject { ["code"] = "de" });
var updated = await context.Client.PutAsJsonAsync(url, update);
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
var saved = (await updated.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data");
Assert.NotEqual(service.GetProperty("revision").GetGuid(), saved.GetProperty("revision").GetGuid());
Assert.Contains(saved.GetProperty("languages").EnumerateArray(), l => l.GetProperty("id").GetGuid() == service.GetProperty("languages")[0].GetProperty("id").GetGuid() && l.GetProperty("code").GetString() == "en-GB");
Assert.Equal(3, saved.GetProperty("languages").GetArrayLength());
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PutAsJsonAsync(url, update)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync("/api/v1/services", new { name = "Renamed API", languages = Array.Empty<object>() })).StatusCode);
}
[Fact]
public async Task SnapshotOmissionOfUsedLanguageFailsWithoutChangingServiceOrDefinition()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await ReadAsync(context);
var definition = await context.ReadAsync();
var update = UpdateBody(before);
update["name"] = "Unsaved API rename";
update["languages"] = new JsonArray(new JsonObject { ["code"] = "fr" });
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PutAsJsonAsync(context.ServiceRoot, update)).StatusCode);
Assert.Equal(before.GetRawText(), (await ReadAsync(context)).GetRawText());
Assert.Equal(definition.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task OrdinaryRemovalOnlyDeletesUnusedCatalogRows_AndReturnsNewRevisionOnRead()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await ReadAsync(context);
var used = before.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "en");
var unused = before.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "uk");
var root = context.ServiceRoot + "/languages/";
Assert.Equal(1, used.GetProperty("versionCount").GetInt32());
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(root + used.GetProperty("id").GetGuid() + "?revision=" + before.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(root + unused.GetProperty("id").GetGuid() + "?revision=" + before.GetProperty("revision").GetGuid())).StatusCode);
var after = await ReadAsync(context);
Assert.NotEqual(before.GetProperty("revision").GetGuid(), after.GetProperty("revision").GetGuid());
Assert.DoesNotContain(after.GetProperty("languages").EnumerateArray(), l => l.GetProperty("code").GetString() == "uk");
Assert.Single((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(root + Guid.NewGuid() + "?revision=" + after.GetProperty("revision").GetGuid())).StatusCode);
}
[Fact]
public async Task ExplicitDestructiveRemovalRequiresConfirmationRevisionAndCurrentCount_AndRetainsDefinitions()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "First");
var service = await ReadAsync(context);
var language = service.GetProperty("languages").EnumerateArray().Single(l => l.GetProperty("code").GetString() == "en");
var url = context.ServiceRoot + "/languages/" + language.GetProperty("id").GetGuid() + "/delete-with-versions";
var body = new JsonObject { ["revision"] = service.GetProperty("revision").GetString(), ["expectedVersionCount"] = 1, ["confirmDelete"] = false };
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body.Remove("confirmDelete");
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body["confirmDelete"] = true;
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "Second");
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body["expectedVersionCount"] = 2;
body["revision"] = Guid.NewGuid().ToString();
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
body["revision"] = service.GetProperty("revision").GetString();
Assert.Equal(HttpStatusCode.OK, (await context.Client.PostAsJsonAsync(url, body)).StatusCode);
var definitions = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot + "/email-template-definitions/all")).GetProperty("data");
Assert.Equal(2, definitions.GetArrayLength());
Assert.All(definitions.EnumerateArray(), d => Assert.Empty(d.GetProperty("translations").EnumerateArray()));
Assert.DoesNotContain((await ReadAsync(context)).GetProperty("languages").EnumerateArray(), l => l.GetProperty("code").GetString() == "en");
}
[Theory]
[InlineData("nullLanguages")]
[InlineData("duplicateCodes")]
[InlineData("foreignId")]
public async Task InvalidCreatePersistsNoService(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = new JsonObject { ["name"] = "InvalidCreate", ["languages"] = new JsonArray(new JsonObject { ["code"] = "en" }) };
switch (scenario)
{
case "nullLanguages": body["languages"] = null; break;
case "duplicateCodes": ((JsonArray)body["languages"]!).Add(new JsonObject { ["code"] = "en" }); break;
case "foreignId": body["languages"]![0]!["id"] = Guid.NewGuid().ToString(); break;
}
Assert.Equal(HttpStatusCode.BadRequest, (await context.Client.PostAsJsonAsync("/api/v1/services", body)).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync("/api/v1/services?name=InvalidCreate")).StatusCode);
}
[Theory]
[InlineData("emptyRevision", HttpStatusCode.BadRequest)]
[InlineData("missingRevision", HttpStatusCode.BadRequest)]
[InlineData("foreignLanguageId", HttpStatusCode.Conflict)]
public async Task InvalidUpdatePreservesCurrentServiceSnapshot(string scenario, HttpStatusCode expectedStatus)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var before = await ReadAsync(context);
var body = UpdateBody(before);
body["name"] = "Unsaved name";
switch (scenario)
{
case "emptyRevision": body["revision"] = Guid.Empty.ToString(); break;
case "missingRevision": body.Remove("revision"); break;
case "foreignLanguageId": body["languages"]![0]!["id"] = Guid.NewGuid().ToString(); break;
}
Assert.Equal(expectedStatus, (await context.Client.PutAsJsonAsync(context.ServiceRoot, body)).StatusCode);
Assert.Equal(before.GetRawText(), (await ReadAsync(context)).GetRawText());
}
private static async Task<JsonElement> ReadAsync(DefinitionApiTestContext context) =>
(await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
private static JsonObject UpdateBody(JsonElement service) => new()
{
["name"] = service.GetProperty("name").GetString(), ["revision"] = service.GetProperty("revision").GetString(),
["languages"] = new JsonArray(service.GetProperty("languages").EnumerateArray().Select(l => (JsonNode)new JsonObject
{ ["id"] = l.GetProperty("id").GetString(), ["code"] = l.GetProperty("code").GetString() }).ToArray())
};
}
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ListsExposeRevisions_AndDefinitionsAreServiceScopedIncludingEmptyDefinitions()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null), "Empty");
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"), "Populated");
using var scope = context.App.Services.CreateScope();
var other = new NotificationServiceInfo { Name = "OtherService" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var services = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all")).GetProperty("data");
Assert.Equal(2, services.GetArrayLength());
Assert.All(services.EnumerateArray(), s => Assert.NotEqual(Guid.Empty, s.GetProperty("revision").GetGuid()));
var definitions = (await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot + "/email-template-definitions/all")).GetProperty("data");
Assert.Equal(new[] { "Empty", "Populated" }, definitions.EnumerateArray().Select(d => d.GetProperty("key").GetString()));
Assert.Empty(definitions[0].GetProperty("translations").EnumerateArray());
Assert.Single(definitions[1].GetProperty("translations").EnumerateArray());
Assert.All(definitions.EnumerateArray(), d => Assert.Equal(context.ServiceId, d.GetProperty("serviceId").GetGuid()));
var otherDefinitions = await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/services/{other.Id}/email-template-definitions/all");
Assert.Empty(otherDefinitions.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}/email-template-definitions/all")).StatusCode);
}
[Fact]
public async Task ExplicitCleanupDeletesDefinitionsThenServiceAndLanguages_ButRetainsChannels()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await SeedChannelAsync(context);
const string key = "Delete / %2F Привіт";
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en", "uk"), key);
var definition = await context.ReadAsync(key);
var service = await ReadServiceAsync(context);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
var deleted = await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid());
Assert.Equal(HttpStatusCode.OK, deleted.StatusCode);
var result = await deleted.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(result.GetProperty("success").GetBoolean());
Assert.Equal(JsonValueKind.Object, result.GetProperty("data").ValueKind);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.DefinitionUrl(key))).StatusCode);
var afterDefinitionDeletion = await ReadServiceAsync(context);
Assert.Equal(service.GetProperty("revision").GetGuid(), afterDefinitionDeletion.GetProperty("revision").GetGuid());
Assert.Equal(service.GetProperty("supportedLanguageCodes").GetRawText(), afterDefinitionDeletion.GetProperty("supportedLanguageCodes").GetRawText());
Assert.All(afterDefinitionDeletion.GetProperty("languages").EnumerateArray(), l => Assert.Equal(0, l.GetProperty("versionCount").GetInt32()));
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.ServiceRoot)).StatusCode);
var all = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all");
Assert.Empty(all.GetProperty("data").EnumerateArray());
using var scope = context.App.Services.CreateScope();
Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.DefinitionUrl(key) + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
}
[Fact]
public async Task DeleteIsRevisionGuarded_AndEvenEmptyDefinitionsBlockServiceRemoval()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null));
var definition = await context.ReadAsync();
var service = await ReadServiceAsync(context);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + Guid.NewGuid())).StatusCode);
using var scope = context.App.Services.CreateScope();
var repository = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var edit = (await repository.GetAsync(context.ServiceId))!;
edit.Name = "RenamedService";
await repository.SaveAsync(edit, false);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + definition.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal("RenamedService", (await ReadServiceAsync(context)).GetProperty("name").GetString());
Assert.Empty((await context.ReadAsync()).GetProperty("translations").EnumerateArray());
}
[Theory]
[InlineData("")]
[InlineData("?revision=not-a-guid")]
[InlineData("?revision=00000000-0000-0000-0000-000000000000")]
public async Task InvalidServiceDeleteDoesNotRemoveAnything(string query)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var before = await ReadServiceAsync(context);
var response = await context.Client.DeleteAsync(context.ServiceRoot + query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await ReadServiceAsync(context)).GetRawText());
}
[Theory]
[InlineData("?key=Welcome")]
[InlineData("?key=Welcome&revision=not-a-guid")]
[InlineData("?key=Welcome&revision=00000000-0000-0000-0000-000000000000")]
[InlineData("?revision=11111111-1111-1111-1111-111111111111")]
public async Task InvalidDefinitionDeleteDoesNotRemoveAnything(string query)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var before = await context.ReadAsync();
var response = await context.Client.DeleteAsync(context.ServiceRoot + "/email-template-definitions" + query);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("InvalidRequest", (await response.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("error").GetProperty("code").GetString());
Assert.Equal(before.GetRawText(), (await context.ReadAsync()).GetRawText());
}
[Fact]
public async Task ConcurrentDefinitionUpdateAndDeleteHaveOneWinner()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var revision = (await context.ReadAsync()).GetProperty("revision").GetGuid();
var responses = await Task.WhenAll(context.UpsertAsync(DefinitionApiTestContext.Body(revision, "uk")), context.Client.DeleteAsync(context.DefinitionUrl() + "&revision=" + revision));
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, responses.Select(r => r.StatusCode).Order().ToArray());
}
[Fact]
public async Task ConcurrentServiceDeleteAndDefinitionCreationCannotLeaveOrphans()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var service = await ReadServiceAsync(context);
var deletion = context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid());
var creation = context.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
var responses = await Task.WhenAll(deletion, creation);
var deleted = responses[0].StatusCode;
var created = responses[1].StatusCode;
Assert.True(deleted == HttpStatusCode.OK && created == HttpStatusCode.NotFound
|| deleted == HttpStatusCode.Conflict && created == HttpStatusCode.Created);
}
private static async Task<JsonElement> ReadServiceAsync(DefinitionApiTestContext context) =>
(await context.Client.GetFromJsonAsync<JsonElement>(context.ServiceRoot)).GetProperty("data");
private static async Task SeedChannelAsync(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
}
}
@@ -0,0 +1,256 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ServiceLanguageTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task EmptyDefinition_CanBeCreatedAndRetainedAfterItsFinalVersionIsRemoved()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = Provider(connection);
var definition = new EmailTemplateDefinition { ServiceName = "TestService", Key = "Empty", Variables = [] };
using (var scope = provider.CreateScope())
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().SaveDefinitionAsync(definition, true);
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
Assert.Contains("data-state=\"missing\"", await client.GetStringAsync("/admin/templates?serviceName=TestService&key=Empty"));
var content = new { serviceName = "TestService", key = "Empty", languageCode = "en", subject = "Hello", htmlBody = "<p>Hello</p>", textBody = "Hello", variables = Array.Empty<object>() };
Assert.Equal(HttpStatusCode.Created, (await client.PostAsJsonAsync("/api/v1/email-templates", content)).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await client.DeleteAsync("/api/v1/email-templates/TestService/Empty/en")).StatusCode);
using var readScope = provider.CreateScope();
var saved = await readScope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync("TestService", "Empty");
Assert.Equal(definition.Id, saved!.Id);
Assert.Empty(saved.Languages);
}
[Fact]
public async Task CatalogDeletion_BlocksOrdinaryRemoval_RequiresCurrentCount_AndRetainsDefinitions()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = Provider(connection);
await AddTemplateAsync(provider, "First", "en");
var original = await ReadServiceAsync(provider);
var language = original.Languages.Single(l => l.Code == "en");
original.Languages = original.Languages.Where(l => l.Id != language.Id).ToList();
using (var scope = provider.CreateScope())
{
var error = await Assert.ThrowsAsync<EmailTemplateWriteException>(() => scope.ServiceProvider
.GetRequiredService<INotificationServiceRepository>().SaveAsync(original, false));
Assert.Contains("1 version(s)", error.Message);
}
await AddTemplateAsync(provider, "Second", "en");
using (var scope = provider.CreateScope())
{
var error = await Assert.ThrowsAsync<EmailTemplateWriteException>(() => scope.ServiceProvider
.GetRequiredService<INotificationServiceRepository>().RemoveLanguageAsync(original.Id, language.Id, 1, original.Revision));
Assert.Contains("count changed", error.Message);
}
var current = await ReadServiceAsync(provider);
Assert.Equal(2, current.Languages.Single(l => l.Code == "en").VersionCount);
using (var scope = provider.CreateScope())
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().RemoveLanguageAsync(current.Id, language.Id, 2, current.Revision);
using var readScope = provider.CreateScope();
var definitions = await readScope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionsAsync("TestService");
Assert.Equal(2, definitions.Count);
Assert.All(definitions, d => Assert.Empty(d.Languages));
Assert.DoesNotContain((await ReadServiceAsync(provider)).Languages, l => l.Code == "en");
}
[Fact]
public async Task ServiceEditor_CanSwapLanguageCodesWithoutLosingVersionIdentity()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = Provider(connection);
await AddTemplateAsync(provider, "First", "en");
var service = await ReadServiceAsync(provider);
var en = service.Languages.Single(l => l.Code == "en");
var uk = service.Languages.Single(l => l.Code == "uk");
en.Code = "uk";
uk.Code = "en";
using (var scope = provider.CreateScope())
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(service, false);
using var readScope = provider.CreateScope();
var version = await readScope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().GetAsync("TestService", "First", "uk");
Assert.NotNull(version);
Assert.Null(await readScope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().GetAsync("TestService", "First", "en"));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task FailedServiceSave_RefreshesUsedLanguageCountsAndRetainsEnteredFields(bool modelValidationFailure)
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = Provider(connection);
await AddTemplateAsync(provider, "First", "en");
var service = await ReadServiceAsync(provider);
var usedIndex = service.Languages.ToList().FindIndex(l => l.Code == "en");
var unusedIndex = service.Languages.ToList().FindIndex(l => l.Code == "uk");
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
var fields = HiddenFields(await client.GetStringAsync($"/admin/services/{service.Id}/edit"));
var revision = fields["Revision"];
var enteredName = modelValidationFailure ? new string('x', 101) : "Entered unsaved name";
fields["Name"] = enteredName;
fields[$"Languages[{usedIndex}].Code"] = "xx";
if (!modelValidationFailure)
fields[$"Languages[{unusedIndex}].Code"] = "de";
var html = await (await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
var usedRow = Assert.Single(Regex.Matches(html, "<tr>.*?</tr>", RegexOptions.Singleline).Cast<Match>(),
row => row.Value.Contains(service.Languages[usedIndex].Id.ToString(), StringComparison.OrdinalIgnoreCase)).Value;
Assert.Contains("data-count=\"1\"", usedRow);
Assert.Contains("<td>1</td>", usedRow);
var returnedFields = HiddenFields(html);
Assert.Equal(enteredName, returnedFields["Name"]);
Assert.Equal("xx", returnedFields[$"Languages[{usedIndex}].Code"]);
Assert.Equal(revision, returnedFields["Revision"]);
Assert.Equal("en", (await ReadServiceAsync(provider)).Languages.Single(l => l.Id == service.Languages[usedIndex].Id).Code);
}
[Fact]
public async Task MigratedNonstandardCode_AllowsUnchangedServiceEditsAndNewLanguages()
{
var connection = await fixture.CreateDatabaseAsync(legacy: true);
var originalVersionId = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en_US", "[]");
await using (var db = TemplateDatabaseFixture.CreateContext(connection))
await db.Database.MigrateAsync();
using var provider = Provider(connection);
var service = await ReadServiceAsync(provider);
service.Name = "RenamedService";
service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = "uk" }];
using (var saveScope = provider.CreateScope())
await saveScope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(service, false);
using var readScope = provider.CreateScope();
var services = readScope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var saved = (await services.GetAsync(service.Id))!;
Assert.Contains(saved.Languages, l => l.Code == "en_US");
Assert.Contains(saved.Languages, l => l.Code == "uk");
var version = await readScope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().GetAsync("RenamedService", "Welcome", "en_US");
Assert.Equal(originalVersionId, version!.Id);
saved.Languages.Single(l => l.Code == "en_US").Code = "en_GB";
var error = await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.SaveAsync(saved, false));
Assert.Contains("new or renamed", error.Message);
}
[Fact]
public async Task LegacyApi_RejectsUnknownServiceLanguage_WithoutCreatingDefinition()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
var content = new { serviceName = "TestService", key = "Unknown", languageCode = "fr", subject = "Hello", htmlBody = "Hello", textBody = "Hello", variables = Array.Empty<object>() };
var response = await client.PostAsJsonAsync("/api/v1/email-templates", content);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("catalog", await response.Content.ReadAsStringAsync());
using var scope = app.Services.CreateScope();
Assert.Null(await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync("TestService", "Unknown"));
}
[Fact]
public async Task DatabaseConstraints_RejectDuplicatesAndLanguageFromAnotherService()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = Provider(connection);
await AddTemplateAsync(provider, "First", "en");
using (var scope = provider.CreateScope())
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(new NotificationServiceInfo
{ Name = "OtherService", Languages = [new ServiceLanguageInfo { Code = "en" }] }, true);
await AssertSqlStateAsync(connection, "23505", "INSERT INTO services SELECT gen_random_uuid(), name, gen_random_uuid(), created, updated FROM services WHERE name = 'TestService'");
await AssertSqlStateAsync(connection, "23505", "INSERT INTO service_languages SELECT gen_random_uuid(), service_id, code, created, updated FROM service_languages LIMIT 1");
await AssertSqlStateAsync(connection, "23505", "INSERT INTO email_template_definitions SELECT gen_random_uuid(), service_id, key, revision, variables, created, updated FROM email_template_definitions LIMIT 1");
await AssertSqlStateAsync(connection, "23505", """
INSERT INTO email_templates(id, definition_id, service_id, service_language_id, subject, html_body, text_body, created)
SELECT gen_random_uuid(), definition_id, service_id, service_language_id, subject, html_body, text_body, created FROM email_templates LIMIT 1
""");
await AssertSqlStateAsync(connection, "23503", """
INSERT INTO email_templates(id, definition_id, service_id, service_language_id, subject, html_body, text_body, created)
SELECT gen_random_uuid(), t.definition_id, t.service_id, l.id, 'Hello', 'Hello', 'Hello', now()
FROM email_templates t CROSS JOIN service_languages l JOIN services s ON s.id = l.service_id WHERE s.name = 'OtherService' LIMIT 1
""");
}
[Fact]
public async Task ServiceForm_CreatesEditsInlineLanguages_RejectsDuplicatesAndRequiresDestructiveConfirmation()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new() { AllowAutoRedirect = false });
var createHtml = await client.GetStringAsync("/admin/services/create");
var fields = HiddenFields(createHtml);
fields["Name"] = "FormService";
fields["Languages[0].Code"] = "ru";
fields["Languages[1].Code"] = "ru-RU";
var createResponse = await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, createResponse.StatusCode);
var createdService = (await app.Services.GetRequiredService<INotificationServiceRepository>().GetAllAsync()).Single(s => s.Name == "FormService");
var serviceUrl = $"/admin/services/{createdService.Id}";
Assert.Equal(serviceUrl, createResponse.Headers.Location!.OriginalString);
var editUrl = serviceUrl + "/edit";
var editHtml = await client.GetStringAsync(editUrl);
Assert.Contains("ru-RU", editHtml);
var savedFields = HiddenFields(editHtml);
var editResponse = await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(savedFields));
Assert.Equal(HttpStatusCode.Redirect, editResponse.StatusCode);
Assert.Equal(serviceUrl, editResponse.Headers.Location!.OriginalString);
savedFields = HiddenFields(await client.GetStringAsync(editUrl));
savedFields["Name"] = "FormServiceRenamed";
savedFields["Languages[0].Code"] = "ru";
savedFields["Languages[1].Code"] = "ru";
Assert.Contains("must be unique", await (await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(savedFields))).Content.ReadAsStringAsync());
savedFields["Languages[1].Code"] = "ru-RU";
var renameResponse = await client.PostAsync("/admin/services/save", new FormUrlEncodedContent(savedFields));
Assert.Equal(HttpStatusCode.Redirect, renameResponse.StatusCode);
Assert.Equal(serviceUrl, renameResponse.Headers.Location!.OriginalString);
var service = (await app.Services.GetRequiredService<INotificationServiceRepository>().GetAllAsync()).Single(s => s.Name == "FormServiceRenamed");
var removalUrl = $"/admin/services/{service.Id}/languages/{service.Languages[0].Id}/delete";
var removalHtml = await client.GetStringAsync(removalUrl);
var removalFields = HiddenFields(removalHtml);
Assert.Contains("0 template version(s)", removalHtml);
Assert.Contains("Confirm deletion", await (await client.PostAsync(removalUrl, new FormUrlEncodedContent(removalFields))).Content.ReadAsStringAsync());
removalFields["ConfirmDelete"] = "true";
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(removalUrl, new FormUrlEncodedContent(removalFields))).StatusCode);
}
private static ServiceProvider Provider(string connection) => new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
private static async Task<NotificationServiceInfo> ReadServiceAsync(ServiceProvider provider)
{
using var scope = provider.CreateScope();
return (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetAllAsync()).Single(s => s.Name == "TestService");
}
private static async Task AddTemplateAsync(ServiceProvider provider, string key, string language)
{
using var scope = provider.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().AddAsync(new EmailTemplate
{
ServiceName = "TestService", Key = key, LanguageCode = language, Subject = "Hello", HtmlBody = "Hello", TextBody = "Hello"
});
}
private static async Task AssertSqlStateAsync(string connection, string code, string sql) =>
Assert.Equal(code, (await Assert.ThrowsAsync<PostgresException>(() => TemplateDatabaseFixture.ExecuteAsync(connection, sql))).SqlState);
private static Dictionary<string, string> HiddenFields(string html)
{
var result = new Dictionary<string, string>();
foreach (Match input in Regex.Matches(html, "<input[^>]+>"))
{
var name = Regex.Match(input.Value, "name=\"([^\"]+)\"");
var value = Regex.Match(input.Value, "value=\"([^\"]*)\"");
if (name.Success && value.Success)
result[WebUtility.HtmlDecode(name.Groups[1].Value)] = WebUtility.HtmlDecode(value.Groups[1].Value);
}
return result;
}
}
@@ -0,0 +1,119 @@
using System.Net;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using HrynCo.NotificationService.Services.EmailTemplates.Delete;
using MediatR;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ServiceTemplateNavigationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ServicesAreTheEntryPoint_AndEachServiceListsOnlyItsOwnTemplates()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAllAsync()).Single();
var other = new NotificationServiceInfo { Name = "OtherService", Languages = [new ServiceLanguageInfo { Code = "en" }] };
await services.SaveAsync(other, true);
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "OwnTemplate" }, true);
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = other.Name, Key = "OtherTemplate" }, true);
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new() { AllowAutoRedirect = false });
Assert.Equal("/admin/services", (await client.GetAsync("/")).Headers.Location!.OriginalString);
Assert.Equal("/admin/services", (await client.GetAsync("/admin/templates")).Headers.Location!.OriginalString);
Assert.Equal($"/admin/services/{service.Id}/templates/create?keyFilter=Welcome",
(await client.GetAsync("/admin/templates/create?serviceNameFilter=TestService&keyFilter=Welcome")).Headers.Location!.OriginalString);
var list = await client.GetStringAsync("/admin/services");
Assert.Contains($"href=\"/admin/services/{service.Id}\">TestService</a>", list);
Assert.Contains($"href=\"/admin/services/{service.Id}/edit\"", list);
Assert.DoesNotContain("href=\"/admin/templates\"", list);
var detail = await client.GetStringAsync($"/admin/services/{service.Id}");
Assert.Contains("OwnTemplate", detail);
Assert.Contains($"href=\"/admin/services/{service.Id}/edit\" class=\"btn btn-outline-primary\"", detail);
Assert.DoesNotContain("OtherTemplate", detail);
Assert.Contains($"/admin/services/{service.Id}/templates/create", detail);
Assert.Contains("data-state=\"missing\"", detail);
Assert.Contains("No templates match this key.", await client.GetStringAsync($"/admin/services/{service.Id}?key=Missing"));
Assert.Contains("Language codes", await client.GetStringAsync($"/admin/services/{service.Id}/edit"));
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync($"/admin/services/{Guid.NewGuid()}" )).StatusCode);
}
[Fact]
public async Task AllConfiguredTabsAllowBlankDefinitions_PopulateAndClearWithoutLosingTheTemplate()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new() { AllowAutoRedirect = false });
using var scope = app.Services.CreateScope();
var service = (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetAllAsync()).Single();
var baseUrl = $"/admin/services/{service.Id}";
var createHtml = await client.GetStringAsync(baseUrl + "/templates/create?keyFilter=Welcome");
Assert.Equal(5, System.Text.RegularExpressions.Regex.Matches(createHtml, "role=\"tab\"").Count);
Assert.DoesNotContain("id=\"addLanguage\"", createHtml);
Assert.DoesNotContain("remove-language", createHtml);
var fields = TemplateEditorTestFields.Read(createHtml);
fields["Key"] = "Welcome";
var response = await client.PostAsync(baseUrl + "/templates/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
Assert.Equal(baseUrl + "?key=Welcome", response.Headers.Location!.OriginalString);
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
var empty = (await definitions.GetDefinitionAsync("TestService", "Welcome"))!;
Assert.Empty(empty.Languages);
var editUrl = baseUrl + "/templates/" + empty.Id;
fields = TemplateEditorTestFields.Read(await client.GetStringAsync(editUrl));
TemplateEditorTestFields.SetLanguage(fields, "uk", "Subject uk", "<p>Hello</p>", "Hello");
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(baseUrl + "/templates/save", new FormUrlEncodedContent(fields))).StatusCode);
Assert.Equal("uk", Assert.Single((await definitions.GetDefinitionAsync("TestService", "Welcome"))!.Languages).LanguageCode);
fields = TemplateEditorTestFields.Read(await client.GetStringAsync(editUrl));
TemplateEditorTestFields.SetLanguage(fields, "uk", "", "", "");
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(baseUrl + "/templates/save", new FormUrlEncodedContent(fields))).StatusCode);
var cleared = (await definitions.GetDefinitionAsync("TestService", "Welcome"))!;
Assert.Equal(empty.Id, cleared.Id);
Assert.Empty(cleared.Languages);
}
[Fact]
public async Task CrossServiceGetSaveAndDeleteAreRejected_WithoutChangingEitherService()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new() { AllowAutoRedirect = false });
using var scope = app.Services.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAllAsync()).Single();
var other = new NotificationServiceInfo { Name = "OtherService", Languages = [new ServiceLanguageInfo { Code = "en" }] };
await services.SaveAsync(other, true);
var repository = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
await repository.AddAsync(new EmailTemplate { ServiceName = service.Name, Key = "Welcome", LanguageCode = "en", Subject = "Original", HtmlBody = "Original", TextBody = "Original" });
await repository.AddAsync(new EmailTemplate { ServiceName = service.Name, Key = "Welcome", LanguageCode = "uk", Subject = "Retained", HtmlBody = "Retained", TextBody = "Retained" });
var retained = (await repository.GetAsync(service.Name, "Welcome", "uk"))!;
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
var definition = (await definitions.GetDefinitionAsync(service.Name, "Welcome"))!;
var foreignBase = $"/admin/services/{other.Id}/templates";
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync(foreignBase + "/" + definition.Id)).StatusCode);
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/templates/{definition.Id}"));
Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync(foreignBase + "/save", new FormUrlEncodedContent(fields))).StatusCode);
fields["ServiceId"] = other.Id.ToString();
fields["ServiceName"] = other.Name;
Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync(foreignBase + "/save", new FormUrlEncodedContent(fields))).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync(foreignBase + $"/{definition.Id}/languages/en/delete", new FormUrlEncodedContent(fields))).StatusCode);
Assert.Equal("Original", (await repository.GetAsync(service.Name, "Welcome", "en"))!.Subject);
Assert.Empty(await definitions.GetDefinitionsAsync(other.Name));
var delete = await client.PostAsync($"/admin/services/{service.Id}/templates/{definition.Id}/languages/en/delete", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, delete.StatusCode);
Assert.Equal($"/admin/services/{service.Id}", delete.Headers.Location!.OriginalString);
Assert.Equal("uk", Assert.Single((await definitions.GetDefinitionAsync(service.Name, "Welcome"))!.Languages).LanguageCode);
Assert.Equal(retained.Updated, (await repository.GetAsync(service.Name, "Welcome", "uk"))!.Updated);
var staleDelete = await scope.ServiceProvider.GetRequiredService<IMediator>()
.Send(new DeleteEmailTemplateCommand(service.Name, "Welcome", "uk", Guid.NewGuid()));
Assert.False(staleDelete.IsSuccess);
Assert.NotNull(await repository.GetAsync(service.Name, "Welcome", "uk"));
Assert.Equal(HttpStatusCode.NotFound, (await client.PostAsync("/admin/templates/save", new FormUrlEncodedContent(fields))).StatusCode);
}
}
@@ -0,0 +1,103 @@
using System.Net;
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 TemplateCatalogPresentationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task CatalogStatusesUseExactCodes_AndEmptyDefinitionsShowEveryLanguageMissing()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var scope = app.Services.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAllAsync()).Single();
service.Languages = new[] { "EN", "en", "ru", "ru-RU" }.Select(code => new ServiceLanguageInfo { Code = code }).ToList();
await services.SaveAsync(service, false);
var versions = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
await versions.AddAsync(new EmailTemplate { ServiceName = service.Name, Key = "CaseCheck", LanguageCode = "en", Subject = "Hello", HtmlBody = "Hello", TextBody = "Hello" });
await versions.AddAsync(new EmailTemplate { ServiceName = service.Name, Key = "CaseCheck", LanguageCode = "ru", Subject = "Hello", HtmlBody = "Hello", TextBody = "Hello" });
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
using var client = app.CreateClient();
var html = await client.GetStringAsync($"/admin/services/{service.Id}");
var catalog = Regex.Match(html, "<div class=\"service-template-catalog.*?</div>", RegexOptions.Singleline).Value;
Assert.Equal(4, Regex.Matches(catalog, "class=\"service-template-language-code\"").Count);
Assert.Contains(">EN</span>", catalog);
Assert.Contains(">en</span>", catalog);
Assert.Contains(">ru-RU</span>", catalog);
var row = TemplateRow(html, "CaseCheck");
Assert.Contains("data-language-code=\"en\" data-state=\"present\"", row);
Assert.Contains("data-language-code=\"EN\" data-state=\"missing\"", row);
Assert.Contains("data-language-code=\"ru\" data-state=\"present\"", row);
Assert.Contains("data-language-code=\"ru-RU\" data-state=\"missing\"", row);
Assert.Equal(2, Regex.Matches(row, ">Present</span>").Count);
Assert.Equal(2, Regex.Matches(row, ">Missing</span>").Count);
var empty = TemplateRow(html, "Empty");
Assert.Equal(4, Regex.Matches(empty, "data-state=\"missing\"").Count);
Assert.DoesNotContain("data-state=\"present\"", empty);
Assert.Equal(2, (await versions.GetAllAsync(service.Name)).Count);
}
[Fact]
public async Task EmbeddedKeyFilterSurvivesMiss_AndClearAndEditorLinksStayWithinTheService()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var scope = app.Services.CreateScope();
var service = (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetAllAsync()).Single();
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Welcome" }, true);
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Other" }, true);
using var client = app.CreateClient();
var baseUrl = $"/admin/services/{service.Id}";
var miss = await client.GetStringAsync(baseUrl + "?key=Absent");
Assert.Matches("(?s)<th[^>]*>\\s*<form[^>]*method=\"get\"[^>]*class=\"template-key-filter\"", miss);
Assert.Contains("value=\"Absent\"", miss);
Assert.Contains("No templates match this key.", miss);
Assert.Contains("class=\"template-actions\">Action</th>", miss);
Assert.Contains($"href=\"{baseUrl}\" class=\"btn btn-outline-secondary btn-sm\">Clear</a>", miss);
var filtered = await client.GetStringAsync(baseUrl + "?key=Welcome");
Assert.Contains("<td>Welcome</td>", filtered);
Assert.DoesNotContain("<td>Other</td>", filtered);
Assert.Contains("keyFilter=Welcome", filtered);
Assert.Contains("aria-label=\"Edit template Welcome\"", filtered);
var cleared = await client.GetStringAsync(baseUrl);
Assert.Contains("<td>Welcome</td>", cleared);
Assert.Contains("<td>Other</td>", cleared);
}
[Fact]
public async Task NoCatalogAndNoDefinitionsHaveExplicitStates_AndRetainTheTableHeader()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var scope = app.Services.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = new NotificationServiceInfo { Name = "NoLanguages" };
await services.SaveAsync(service, true);
using var client = app.CreateClient();
var baseUrl = $"/admin/services/{service.Id}";
var empty = await client.GetStringAsync(baseUrl);
Assert.Contains("No languages configured for this service.", empty);
Assert.Contains("template-key-filter", empty);
Assert.Contains("No email templates for this service yet.", empty);
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
var withDefinition = await client.GetStringAsync(baseUrl);
Assert.Contains("No configured languages", TemplateRow(withDefinition, "Empty"));
Assert.DoesNotContain("data-state=", withDefinition);
Assert.DoesNotContain("No templates match this key.", withDefinition);
}
private static string TemplateRow(string html, string key)
{
var row = Regex.Match(html, "<tr>\\s*<td>" + Regex.Escape(WebUtility.HtmlEncode(key)) + "</td>.*?</tr>", RegexOptions.Singleline).Value;
Assert.NotEmpty(row);
return row;
}
}
@@ -0,0 +1,71 @@
using HrynCo.NotificationService.DAL.EF;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql;
using Testcontainers.PostgreSql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class TemplateDatabaseFixture : IAsyncLifetime
{
public const string LegacyMigration = "20260502154249_PendingChanges";
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder().WithImage("postgres:17-alpine").Build();
public Task InitializeAsync() => _postgres.StartAsync();
public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();
public async Task<string> CreateDatabaseAsync(bool legacy = false)
{
var name = "templates_" + Guid.NewGuid().ToString("N");
await using var connection = new NpgsqlConnection(_postgres.GetConnectionString());
await connection.OpenAsync();
await using var command = new NpgsqlCommand($"CREATE DATABASE {name}", connection);
await command.ExecuteNonQueryAsync();
var builder = new NpgsqlConnectionStringBuilder(_postgres.GetConnectionString()) { Database = name };
await using var db = CreateContext(builder.ConnectionString);
await db.GetService<IMigrator>().MigrateAsync(legacy ? LegacyMigration : null);
if (!legacy)
{
using var provider = new ServiceCollection().AddNotificationDataAccess(builder.ConnectionString).BuildServiceProvider();
using var scope = provider.CreateScope();
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(new NotificationServiceInfo
{
Name = "TestService", Languages = new[] { "en", "uk", "de", "ru", "ru-RU" }
.Select(code => new ServiceLanguageInfo { Code = code }).ToList()
}, true);
}
return builder.ConnectionString;
}
public static NotificationDbContext CreateContext(string connectionString) => new(
new DbContextOptionsBuilder<NotificationDbContext>().UseNpgsql(connectionString).Options);
public static async Task ExecuteAsync(string connectionString, string sql)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand(sql, connection);
await command.ExecuteNonQueryAsync();
}
public static async Task<Guid> InsertLegacyAsync(string connectionString, string language, string variables)
{
var id = Guid.NewGuid();
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand("""
INSERT INTO email_templates(id, service_name, key, language_code, subject, html_body, text_body, variables, created)
VALUES (@id, 'TestService', 'Welcome', @language, @subject, '<p>{{Name}}</p>', 'Hello {{Name}}', @variables, now())
""", connection);
command.Parameters.AddWithValue("id", id);
command.Parameters.AddWithValue("language", language);
command.Parameters.AddWithValue("subject", "Subject " + language);
command.Parameters.AddWithValue("variables", NpgsqlTypes.NpgsqlDbType.Jsonb, variables);
await command.ExecuteNonQueryAsync();
return id;
}
}
@@ -0,0 +1,31 @@
using System.Net;
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public static class TemplateEditorTestFields
{
public static Dictionary<string, string> Read(string html)
{
var fields = new Dictionary<string, string>();
foreach (Match input in Regex.Matches(html, "<input[^>]+>"))
{
var name = Regex.Match(input.Value, "name=\"([^\"]+)\"");
var value = Regex.Match(input.Value, "value=\"([^\"]*)\"");
if (name.Success && value.Success)
fields[WebUtility.HtmlDecode(name.Groups[1].Value)] = WebUtility.HtmlDecode(value.Groups[1].Value);
}
foreach (Match textarea in Regex.Matches(html, "<textarea[^>]+name=\"([^\"]+)\"[^>]*>(.*?)</textarea>", RegexOptions.Singleline))
fields[WebUtility.HtmlDecode(textarea.Groups[1].Value)] = WebUtility.HtmlDecode(textarea.Groups[2].Value);
return fields;
}
public static void SetLanguage(Dictionary<string, string> fields, string code, string subject, string html, string text)
{
var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == code).Key;
var prefix = codeField[..^"LanguageCode".Length];
fields[prefix + "Subject"] = subject;
fields[prefix + "HtmlBody"] = html;
fields[prefix + "TextBody"] = text;
}
}
@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class TemplateWebApplicationFactory(string connectionString) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.UseSetting("App:ConnectionString", connectionString);
}
}
@@ -0,0 +1,15 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class UnavailableEmailTemplateDefinitionRepository : IEmailTemplateDefinitionRepository
{
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<EmailTemplateDefinition?> GetDefinitionAsync(string serviceName, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<EmailTemplateDefinition?> GetDefinitionAsync(Guid serviceId, string key, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<EmailTemplateDefinition?> UpsertDefinitionAsync(EmailTemplateDefinitionPatch patch, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task<bool> DeleteDefinitionAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) => throw new IOException("Simulated unavailable repository.");
}
@@ -0,0 +1,93 @@
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using HrynCo.NotificationService.Worker.Services;
using HrynCo.NotificationService.Worker.Services.EmailProcessing;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class WorkerTemplateIntegrationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task Worker_UsesExactLanguageContentAndCurrentSharedContract()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).AddNotificationWorkerServices().BuildServiceProvider();
var codes = new[] { "de", "en", "ru", "uk", "ru-RU" };
foreach (var code in codes)
await AddVersionAsync(provider, code);
foreach (var code in codes)
{
using var readScope = provider.CreateScope();
var template = await readScope.ServiceProvider.GetRequiredService<IEmailTemplateService>().GetAsync("TestService", "Delivery", code, default);
var rendered = readScope.ServiceProvider.GetRequiredService<IEmailTemplateRenderingService>().Render(template, Message(code));
Assert.Equal(code + " A < B", rendered.Subject);
Assert.Equal("<p>A &lt; B</p>", rendered.HtmlBody);
Assert.Equal("A < B", rendered.TextBody);
}
using (var updateScope = provider.CreateScope())
{
var definitions = updateScope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
var definition = (await definitions.GetDefinitionAsync("TestService", "Delivery"))!;
definition.Variables = [new EmailTemplateVariable { Name = "Extra", Required = true }];
await definitions.SaveDefinitionAsync(definition, false);
}
using var finalScope = provider.CreateScope();
var loader = finalScope.ServiceProvider.GetRequiredService<IEmailTemplateService>();
var updated = await loader.GetAsync("TestService", "Delivery", "ru-RU", default);
var error = Assert.Throws<InvalidDataException>(() => finalScope.ServiceProvider.GetRequiredService<IEmailTemplateRenderingService>().Render(updated, Message("ru-RU")));
Assert.Contains("Extra", error.Message);
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.GetAsync("TestService", "Delivery", "RU-ru", default));
await Assert.ThrowsAsync<InvalidOperationException>(() => loader.GetAsync("TestService", "Missing", "en", default));
}
[Fact]
public async Task Worker_CompatibilityRetryPreservesExactVersionPriorityAndRepositoryCodes()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).AddNotificationWorkerServices().BuildServiceProvider();
await AddVersionAsync(provider, "ru");
await AddVersionAsync(provider, "ru-RU");
using (var legacyScope = provider.CreateScope())
{
var loader = legacyScope.ServiceProvider.GetRequiredService<IEmailTemplateService>();
Assert.Equal("ru", (await loader.GetAsync("TestService", "Delivery", "RU", default)).LanguageCode);
Assert.Equal("ru", (await loader.GetAsync("TestService", "Delivery", " ru ", default)).LanguageCode);
Assert.Null(await legacyScope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().GetAsync("TestService", "Delivery", "RU"));
}
using (var catalogScope = provider.CreateScope())
{
var services = catalogScope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAllAsync()).Single(s => s.Name == "TestService");
service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = "RU" }];
await services.SaveAsync(service, false);
}
await AddVersionAsync(provider, "RU");
using var exactScope = provider.CreateScope();
var exactLoader = exactScope.ServiceProvider.GetRequiredService<IEmailTemplateService>();
Assert.Equal("RU", (await exactLoader.GetAsync("TestService", "Delivery", "RU", default)).LanguageCode);
Assert.Equal("ru-RU", (await exactLoader.GetAsync("TestService", "Delivery", "ru-RU", default)).LanguageCode);
Assert.Equal("ru", (await exactLoader.GetAsync("TestService", "Delivery", "ru", default)).LanguageCode);
await Assert.ThrowsAsync<InvalidOperationException>(() => exactLoader.GetAsync("TestService", "Delivery", "RU-ru", default));
await Assert.ThrowsAsync<InvalidOperationException>(() => exactLoader.GetAsync("TestService", "Delivery", "zz", default));
}
private static async Task AddVersionAsync(ServiceProvider provider, string code)
{
using var scope = provider.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>().AddAsync(new EmailTemplate
{
ServiceName = "TestService", Key = "Delivery", LanguageCode = code,
Subject = code + " {{Name}}", HtmlBody = "<p>{{Name}}</p>", TextBody = "{{Name}}",
Variables = [new EmailTemplateVariable { Name = "Name", Required = true }]
});
}
private static SendEmailMessageData Message(string code) => new()
{
ServiceName = "TestService", TemplateKey = "Delivery", LanguageCode = code,
RecipientEmail = "test@example.invalid", RecipientName = "Test", Variables = new Dictionary<string, string> { ["Name"] = "A < B" }
};
}
@@ -0,0 +1,177 @@
using HrynCo.NotificationService.Services.TemplateDefinitions;
using MediatR;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/services")]
public sealed class AdminServicesController(INotificationServiceRepository services, ILogger<AdminServicesController> logger,
IEmailTemplateDefinitionRepository definitions, IMediator mediator) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index(CancellationToken ct) => View(await services.GetAllAsync(ct));
[HttpGet("create")]
public IActionResult Create() => View("Edit", new NotificationServiceEditViewModel());
[HttpGet("{id:guid}/edit")]
public async Task<IActionResult> Edit([FromRoute] Guid id, CancellationToken ct)
{
var service = await services.GetAsync(id, ct);
return service is null ? NotFound() : View(ToViewModel(service));
}
[HttpPost("save")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(NotificationServiceEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid)
return await ServiceEditorViewAsync(model, ct);
try
{
var service = new NotificationServiceInfo
{
Id = model.Id ?? Guid.Empty, Name = model.Name, Revision = model.Revision, Languages = model.Languages
};
await services.SaveAsync(service, model.IsNew, ct);
return RedirectToAction(nameof(AdminTemplatesController.Index), "AdminTemplates", new { serviceId = service.Id });
}
catch (EmailTemplateWriteException error)
{
ModelState.AddModelError("", error.Message);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to save notification service.");
ModelState.AddModelError("", "Could not save the service. Your entered values are retained; try again.");
}
return await ServiceEditorViewAsync(model, ct);
}
[HttpGet("{serviceId:guid}/languages/{languageId:guid}/delete")]
public async Task<IActionResult> ConfirmLanguageRemoval([FromRoute] Guid serviceId, [FromRoute] Guid languageId, CancellationToken ct)
{
var model = await RemovalModelAsync(serviceId, languageId, ct);
return model is null ? NotFound() : View("DeleteLanguage", model);
}
[HttpPost("{serviceId:guid}/languages/{languageId:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveLanguage([FromRoute] Guid serviceId, [FromRoute] Guid languageId, ServiceLanguageDeleteViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid || !model.ConfirmDelete)
return await RemovalErrorAsync(serviceId, languageId, "Confirm deletion of the language and all listed versions to continue.", ct);
try
{
await services.RemoveLanguageAsync(serviceId, languageId, model.VersionCount, model.Revision, ct);
return RedirectToAction(nameof(Edit), new { id = serviceId });
}
catch (EmailTemplateWriteException error)
{
return await RemovalErrorAsync(serviceId, languageId, error.Message, ct);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to remove service language.");
return await RemovalErrorAsync(serviceId, languageId, "Could not delete the language. No changes were saved; review the count and try again.", ct);
}
}
[HttpGet("{serviceId:guid}/delete")]
public async Task<IActionResult> ConfirmServiceDelete([FromRoute] Guid serviceId, CancellationToken ct)
{
var model = await ServiceDeleteModelAsync(serviceId, ct);
return model is null ? NotFound() : View("DeleteService", model);
}
[HttpPost("{serviceId:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteService([FromRoute] Guid serviceId, NotificationServiceDeleteViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid || !model.ConfirmDelete)
return await ServiceDeleteErrorAsync(serviceId, "Confirm deletion of this service to continue.", ct);
try
{
var result = await mediator.Send(new DeleteNotificationServiceCommand(serviceId, model.Revision), ct);
if (result.IsSuccess)
return RedirectToAction(nameof(Index));
return await ServiceDeleteErrorAsync(serviceId, result.Error!.Message, ct);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to delete notification service.");
return await ServiceDeleteErrorAsync(serviceId, "Could not complete deletion. Review the current service before trying again.", ct);
}
}
private async Task<NotificationServiceDeleteViewModel?> ServiceDeleteModelAsync(Guid serviceId, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null)
return null;
return new NotificationServiceDeleteViewModel
{
ServiceId = service.Id, Name = service.Name, Revision = service.Revision,
DefinitionCount = (await definitions.GetDefinitionsAsync(serviceId, ct)).Count
};
}
private async Task<IActionResult> ServiceDeleteErrorAsync(Guid serviceId, string error, CancellationToken ct)
{
var model = await ServiceDeleteModelAsync(serviceId, ct);
if (model is null)
return NotFound();
ModelState.Clear();
ModelState.AddModelError("", error);
return View("DeleteService", model);
}
private async Task<IActionResult> ServiceEditorViewAsync(NotificationServiceEditViewModel model, CancellationToken ct)
{
if (model.Id is not Guid id)
return View("Edit", model);
try
{
var persisted = await services.GetAsync(id, ct);
var counts = persisted?.Languages.ToDictionary(l => l.Id, l => l.VersionCount) ?? [];
foreach (var language in model.Languages)
language.VersionCount = counts.GetValueOrDefault(language.Id);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to refresh service language version counts.");
ViewData["LanguageCountsUnavailable"] = true;
ModelState.AddModelError("", "Could not refresh language usage counts. Removal is disabled until the service is available.");
}
return View("Edit", model);
}
private async Task<IActionResult> RemovalErrorAsync(Guid serviceId, Guid languageId, string error, CancellationToken ct)
{
var model = await RemovalModelAsync(serviceId, languageId, ct);
if (model is null)
return NotFound();
ModelState.Clear();
ModelState.AddModelError("", error);
return View("DeleteLanguage", model);
}
private async Task<ServiceLanguageDeleteViewModel?> RemovalModelAsync(Guid serviceId, Guid languageId, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var language = service?.Languages.SingleOrDefault(l => l.Id == languageId);
return language is null ? null : new ServiceLanguageDeleteViewModel
{
ServiceId = serviceId, LanguageId = languageId, ServiceName = service!.Name, Revision = service.Revision,
Code = language.Code, VersionCount = language.VersionCount
};
}
private static NotificationServiceEditViewModel ToViewModel(NotificationServiceInfo service) => new()
{
Id = service.Id, Name = service.Name, Revision = service.Revision, Languages = service.Languages.ToList()
};
}
@@ -1,159 +1,232 @@
using HrynCo.NotificationService.Services.TemplateDefinitions;
using System.Text.Json; using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates; using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.EmailTemplates.Create;
using HrynCo.NotificationService.Services.EmailTemplates.Delete; using HrynCo.NotificationService.Services.EmailTemplates.Delete;
using HrynCo.NotificationService.Services.EmailTemplates.Get; using HrynCo.NotificationService.Services.EmailTemplates.SaveDefinition;
using HrynCo.NotificationService.Services.EmailTemplates.GetAll;
using HrynCo.NotificationService.Services.EmailTemplates.Update;
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin; namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/templates")] [Route("admin/services/{serviceId:guid}/templates")]
public class AdminTemplatesController : Controller public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions,
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services) : Controller
{ {
private readonly IMediator _mediator; private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
public AdminTemplatesController(IMediator mediator) [HttpGet("/admin/services/{serviceId:guid}")]
public async Task<IActionResult> Index([FromRoute] Guid serviceId, [FromQuery] string? key, CancellationToken ct)
{ {
_mediator = mediator; var service = await services.GetAsync(serviceId, ct);
} if (service is null)
// GET /admin/templates
[HttpGet("")]
public async Task<IActionResult> Index([FromQuery] string? serviceName, [FromQuery] string? key, CancellationToken ct)
{
ViewData["ServiceNameFilter"] = serviceName;
ViewData["KeyFilter"] = key;
var result = await _mediator.Send(new GetAllEmailTemplatesQuery(serviceName, key), ct);
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to load templates.");
return View(Array.Empty<EmailTemplate>());
}
return View(result.Result);
}
// GET /admin/templates/create
[HttpGet("create")]
public IActionResult Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter)
{
return View("Edit", new EmailTemplateEditViewModel
{
ServiceNameFilter = serviceNameFilter,
KeyFilter = keyFilter
});
}
// GET /admin/templates/{serviceName}/{key}/{languageCode}
[HttpGet("{serviceName}/{key}/{languageCode}")]
public async Task<IActionResult> Edit(
string serviceName,
string key,
string languageCode,
[FromQuery] string? serviceNameFilter,
[FromQuery] string? keyFilter,
CancellationToken ct)
{
var result = await _mediator.Send(new GetEmailTemplateQuery(serviceName, key, languageCode), ct);
if (!result.IsSuccess || result.Result is null)
return NotFound(); return NotFound();
var model = new ServiceTemplatesViewModel { Service = service, KeyFilter = key };
var template = result.Result; if (TempData["TemplateError"] is string previousError)
var vm = new EmailTemplateEditViewModel ModelState.AddModelError("", previousError);
{
Id = template.Id,
ServiceName = template.ServiceName,
Key = template.Key,
LanguageCode = template.LanguageCode,
Subject = template.Subject,
HtmlBody = template.HtmlBody,
TextBody = template.TextBody,
VariablesJson = JsonSerializer.Serialize(template.Variables),
ServiceNameFilter = serviceNameFilter,
KeyFilter = keyFilter
};
return View(vm);
}
// POST /admin/templates/save
[HttpPost("save")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(EmailTemplateEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid)
return View("Edit", model);
List<EmailTemplateVariable> variables;
try try
{ {
variables = JsonSerializer.Deserialize<List<EmailTemplateVariable>>(model.VariablesJson, model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct))
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) .Where(d => d.ServiceId == serviceId).ToList();
?? []; }
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to load service template definitions.");
ModelState.AddModelError("", "Could not load templates. Try again when the service is available.");
}
return View(model);
}
[HttpGet("create")]
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel
{
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter
}, service);
}
[HttpGet("{definitionId:guid}")]
public async Task<IActionResult> Edit([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, [FromQuery] string? languageCode,
[FromQuery] string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await OwnedDefinitionAsync(service, definitionId, ct);
if (definition is null)
return NotFound();
var model = ToViewModel(definition);
model.KeyFilter = keyFilter;
model.ActiveLanguageIndex = Math.Max(0, model.Languages.FindIndex(l => l.LanguageCode == languageCode));
return EditorView(model, service!);
}
[HttpPost("save")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save([FromRoute] Guid serviceId, EmailTemplateEditViewModel model, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null || model.ServiceId != serviceId || model.ServiceName != service.Name)
return NotFound();
if (model.Id is Guid definitionId && await OwnedDefinitionAsync(service, definitionId, ct) is null)
return NotFound();
ValidateLanguageCatalog(model, service);
if (!ModelState.IsValid)
return EditorView(model, service);
var definition = ParseDefinition(model);
if (definition is null)
return EditorView(model, service);
return await SaveDefinitionAsync(definition, model, service, ct);
}
[HttpPost("{definitionId:guid}/languages/{languageCode}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, [FromRoute] Guid definitionId, [FromRoute] string languageCode,
[FromForm] string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await OwnedDefinitionAsync(service, definitionId, ct);
var language = definition?.Languages.SingleOrDefault(l => l.LanguageCode == languageCode);
if (language is null)
return NotFound();
var result = await mediator.Send(new DeleteEmailTemplateCommand(service!.Name, definition!.Key, languageCode, language.Id), ct);
if (!result.IsSuccess)
TempData["TemplateError"] = result.Error?.Message ?? "Failed to delete language version.";
return RedirectToAction(nameof(Index), new { serviceId, key = keyFilter });
}
[HttpGet("{definitionId:guid}/delete")]
public async Task<IActionResult> ConfirmDefinitionDelete([FromRoute] Guid serviceId, [FromRoute] Guid definitionId,
[FromQuery] string? keyFilter, CancellationToken ct)
{
var model = await DefinitionDeleteModelAsync(serviceId, definitionId, keyFilter, ct);
return model is null ? NotFound() : View("DeleteDefinition", model);
}
[HttpPost("{definitionId:guid}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteDefinition([FromRoute] Guid serviceId, [FromRoute] Guid definitionId,
EmailTemplateDefinitionDeleteViewModel model, CancellationToken ct)
{
var current = await DefinitionDeleteModelAsync(serviceId, definitionId, model.KeyFilter, ct);
if (current is null)
return NotFound();
if (!ModelState.IsValid || !model.ConfirmDelete)
return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, "Confirm deletion of this template and its translations to continue.", ct);
try
{
var result = await mediator.Send(new DeleteEmailTemplateDefinitionCommand(serviceId, current.Key, model.Revision), ct);
if (result.IsSuccess)
return RedirectToAction(nameof(Index), new { serviceId, key = model.KeyFilter });
return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, result.Error!.Message, ct);
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to delete email template definition.");
return await DefinitionDeleteErrorAsync(serviceId, definitionId, model.KeyFilter, "Could not complete deletion. Review the current template before trying again.", ct);
}
}
private async Task<EmailTemplateDefinitionDeleteViewModel?> DefinitionDeleteModelAsync(Guid serviceId, Guid definitionId, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await OwnedDefinitionAsync(service, definitionId, ct);
return definition is null ? null : new EmailTemplateDefinitionDeleteViewModel
{
ServiceId = serviceId, DefinitionId = definition.Id, ServiceName = definition.ServiceName, Key = definition.Key,
Revision = definition.Revision, KeyFilter = keyFilter, LanguageCodes = definition.Languages.Select(l => l.LanguageCode).ToList()
};
}
private async Task<IActionResult> DefinitionDeleteErrorAsync(Guid serviceId, Guid definitionId, string? keyFilter, string error, CancellationToken ct)
{
var model = await DefinitionDeleteModelAsync(serviceId, definitionId, keyFilter, ct);
if (model is null)
return NotFound();
ModelState.Clear();
ModelState.AddModelError("", error);
return View("DeleteDefinition", model);
}
private async Task<IActionResult> SaveDefinitionAsync(EmailTemplateDefinition definition, EmailTemplateEditViewModel model,
NotificationServiceInfo service, CancellationToken ct)
{
try
{
var result = await mediator.Send(new SaveEmailTemplateDefinitionCommand(definition, model.IsNew), ct);
if (result.IsSuccess)
return RedirectToAction(nameof(Index), new { serviceId = service.Id, key = model.KeyFilter });
ModelState.AddModelError("", result.Error?.Message ?? "Failed to save template.");
}
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to save email template definition.");
ModelState.AddModelError("", "Could not save the template. Your entered values are retained; try again when the service is available.");
}
return EditorView(model, service);
}
private async Task<EmailTemplateDefinition?> OwnedDefinitionAsync(NotificationServiceInfo? service, Guid definitionId, CancellationToken ct)
{
if (service is null)
return null;
return (await definitions.GetDefinitionsAsync(service.Id, ct))
.SingleOrDefault(d => d.Id == definitionId && d.ServiceId == service.Id);
}
private IActionResult EditorView(EmailTemplateEditViewModel model, NotificationServiceInfo service)
{
foreach (var language in service.Languages)
if (!model.Languages.Any(l => l.LanguageCode == language.Code))
model.Languages.Add(new EmailTemplateLanguageEditViewModel { LanguageCode = language.Code });
return View("Edit", model);
}
private void ValidateLanguageCatalog(EmailTemplateEditViewModel model, NotificationServiceInfo service)
{
var codes = model.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
if (codes.Count != model.Languages.Count)
ModelState.AddModelError("", "Language codes must be unique within a template.");
if (!codes.SetEquals(service.Languages.Select(l => l.Code)))
ModelState.AddModelError("", "The service language catalog changed or the submitted tabs are invalid. Reload before saving.");
}
private EmailTemplateDefinition? ParseDefinition(EmailTemplateEditViewModel model)
{
try
{
return new EmailTemplateDefinition
{
Id = model.Id ?? Guid.NewGuid(), ServiceId = model.ServiceId, Revision = model.Revision,
ServiceName = model.ServiceName, Key = model.Key,
Variables = EmailTemplateValidation.ParseVariables(model.VariablesJson),
Languages = model.Languages.Where(l => l.HasContent).Select(l => new EmailTemplate
{
ServiceName = model.ServiceName, Key = model.Key, LanguageCode = l.LanguageCode,
Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody, Created = DateTimeOffset.UtcNow
}).ToList()
};
} }
catch (JsonException) catch (JsonException)
{ {
ModelState.AddModelError(nameof(model.VariablesJson), "Invalid JSON format for variables."); ModelState.AddModelError(nameof(model.VariablesJson), "Invalid variables JSON. Use an array of objects with name and required properties.");
return View("Edit", model);
} }
catch (EmailTemplateWriteException error)
if (model.IsNew)
{ {
var command = new CreateEmailTemplateCommand( ModelState.AddModelError(nameof(model.VariablesJson), error.Message);
model.ServiceName,
model.Key,
model.LanguageCode,
model.Subject,
model.HtmlBody,
model.TextBody,
variables);
var result = await _mediator.Send(command, ct);
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create template.");
return View("Edit", model);
}
} }
else return null;
{
var command = new UpdateEmailTemplateCommand(
model.ServiceName,
model.Key,
model.LanguageCode,
model.Subject,
model.HtmlBody,
model.TextBody,
variables);
var result = await _mediator.Send(command, ct);
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update template.");
return View("Edit", model);
}
}
return RedirectToAction(nameof(Index), new { serviceName = model.ServiceNameFilter, key = model.KeyFilter });
} }
// POST /admin/templates/{serviceName}/{key}/{languageCode}/delete private static EmailTemplateEditViewModel ToViewModel(EmailTemplateDefinition definition) => new()
[HttpPost("{serviceName}/{key}/{languageCode}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(
string serviceName,
string key,
string languageCode,
[FromForm] string? serviceNameFilter,
[FromForm] string? keyFilter,
CancellationToken ct)
{ {
await _mediator.Send(new DeleteEmailTemplateCommand(serviceName, key, languageCode), ct); Id = definition.Id, ServiceId = definition.ServiceId, Revision = definition.Revision,
return RedirectToAction(nameof(Index), new { serviceName = serviceNameFilter, key = keyFilter }); ServiceName = definition.ServiceName, Key = definition.Key,
} VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions),
Languages = definition.Languages.Select(l => new EmailTemplateLanguageEditViewModel
{
LanguageCode = l.LanguageCode, Subject = l.Subject, HtmlBody = l.HtmlBody, TextBody = l.TextBody
}).ToList()
};
} }
@@ -0,0 +1,38 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/templates")]
public sealed class LegacyAdminTemplatesController(INotificationServiceRepository services,
IEmailTemplateDefinitionRepository definitions) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index([FromQuery] string? serviceName, [FromQuery] string? serviceNameFilter,
[FromQuery] string? key, [FromQuery] string? keyFilter, CancellationToken ct)
{
var name = serviceName ?? serviceNameFilter;
var service = (await services.GetAllAsync(ct)).SingleOrDefault(s => s.Name == name);
if (service is null)
return RedirectToAction("Index", "AdminServices");
return RedirectToAction("Index", "AdminTemplates", new { serviceId = service.Id, key = key ?? keyFilter });
}
[HttpGet("create")]
public async Task<IActionResult> Create([FromQuery] string? serviceNameFilter, [FromQuery] string? keyFilter, CancellationToken ct)
{
var service = (await services.GetAllAsync(ct)).SingleOrDefault(s => s.Name == serviceNameFilter);
return service is null ? RedirectToAction("Index", "AdminServices")
: RedirectToAction("Create", "AdminTemplates", new { serviceId = service.Id, keyFilter });
}
[HttpGet("{serviceName}/{key}")]
[HttpGet("{serviceName}/{key}/{languageCode}")]
public async Task<IActionResult> Edit(string serviceName, string key, string? languageCode,
[FromQuery] string? keyFilter, CancellationToken ct)
{
var definition = await definitions.GetDefinitionAsync(serviceName, key, ct);
return definition is null ? NotFound() : RedirectToAction("Edit", "AdminTemplates",
new { serviceId = definition.ServiceId, definitionId = definition.Id, languageCode, keyFilter });
}
}
@@ -0,0 +1,13 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class EmailTemplateDefinitionDeleteViewModel
{
public Guid ServiceId { get; init; }
public Guid DefinitionId { get; init; }
public string ServiceName { get; init; } = "";
public string Key { get; init; } = "";
public Guid Revision { get; set; }
public string? KeyFilter { get; set; }
public IReadOnlyList<string> LanguageCodes { get; init; } = [];
public bool ConfirmDelete { get; set; }
}
@@ -2,32 +2,20 @@ using System.ComponentModel.DataAnnotations;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public class EmailTemplateEditViewModel public sealed class EmailTemplateEditViewModel
{ {
public Guid? Id { get; set; } public Guid? Id { get; set; }
public Guid ServiceId { get; set; }
[Required] public Guid Revision { get; set; }
[Required, StringLength(100)]
public string ServiceName { get; set; } = ""; public string ServiceName { get; set; } = "";
[Required, StringLength(100)]
[Required]
public string Key { get; set; } = ""; public string Key { get; set; } = "";
[Required] [Required]
public string LanguageCode { get; set; } = "";
[Required]
public string Subject { get; set; } = "";
[Required]
public string HtmlBody { get; set; } = "";
public string TextBody { get; set; } = "";
// JSON array: [{"name":"UserName","required":true}, ...]
public string VariablesJson { get; set; } = "[]"; public string VariablesJson { get; set; } = "[]";
public string? ServiceNameFilter { get; set; } public List<EmailTemplateLanguageEditViewModel> Languages { get; set; } = [];
public int ActiveLanguageIndex { get; set; }
public string? KeyFilter { get; set; } public string? KeyFilter { get; set; }
public bool IsNew => Id == null; public bool IsNew => Id == null;
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template"; public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
} }
@@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class EmailTemplateLanguageEditViewModel : IValidatableObject
{
[Required, StringLength(10)]
public string LanguageCode { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string Subject { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string HtmlBody { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string TextBody { get; set; } = "";
public bool HasContent => !string.IsNullOrWhiteSpace(Subject) || !string.IsNullOrWhiteSpace(HtmlBody) || !string.IsNullOrWhiteSpace(TextBody);
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!HasContent)
yield break;
if (string.IsNullOrWhiteSpace(Subject))
yield return new ValidationResult("The Subject field is required for a populated language.", [nameof(Subject)]);
if (string.IsNullOrWhiteSpace(HtmlBody))
yield return new ValidationResult("The HtmlBody field is required for a populated language.", [nameof(HtmlBody)]);
if (string.IsNullOrWhiteSpace(TextBody))
yield return new ValidationResult("The TextBody field is required for a populated language.", [nameof(TextBody)]);
}
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class NotificationServiceDeleteViewModel
{
public Guid ServiceId { get; init; }
public string Name { get; init; } = "";
public Guid Revision { get; set; }
public int DefinitionCount { get; init; }
public bool ConfirmDelete { get; set; }
}
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class NotificationServiceEditViewModel
{
public Guid? Id { get; set; }
public Guid Revision { get; set; }
[Required, StringLength(100)]
public string Name { get; set; } = "";
public List<ServiceLanguageInfo> Languages { get; set; } = [];
public bool IsNew => Id is null;
}
@@ -0,0 +1,12 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class ServiceLanguageDeleteViewModel
{
public Guid ServiceId { get; set; }
public Guid LanguageId { get; set; }
public string ServiceName { get; set; } = "";
public string Code { get; set; } = "";
public Guid Revision { get; set; }
public int VersionCount { get; set; }
public bool ConfirmDelete { get; set; }
}
@@ -0,0 +1,10 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class ServiceTemplatesViewModel
{
public required NotificationServiceInfo Service { get; init; }
public string? KeyFilter { get; init; }
public IReadOnlyList<EmailTemplateDefinition> Definitions { get; set; } = [];
}
@@ -1,12 +1,13 @@
using System.ComponentModel.DataAnnotations;
using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels; namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record CreateEmailChannelRequest( public sealed record CreateEmailChannelRequest(
string ServiceName, [Required, StringLength(100)] string ServiceName,
int Priority, int Priority,
EmailChannelType ChannelType, EmailChannelType ChannelType,
EmailChannelSettings Settings, SmtpChannelSettingsDto Settings,
int? DailyLimit, int? DailyLimit,
int? MonthlyLimit, int? MonthlyLimit,
int WarnThresholdPercent, int WarnThresholdPercent,

Some files were not shown because too many files have changed in this diff Show More