feat(templates): add service language template management
Add normalized service and template catalogs, scoped administration, migration, validation, and regression coverage. Ref: IT-1601
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
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 SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default);
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
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 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,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; }
|
||||||
|
}
|
||||||
+29
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-40
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
@@ -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; }
|
||||||
|
}
|
||||||
Generated
+376
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+284
@@ -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);
|
||||||
|
""";
|
||||||
|
}
|
||||||
+182
-31
@@ -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>();
|
||||||
|
|||||||
@@ -1,120 +1,232 @@
|
|||||||
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 Task AddAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
|
||||||
{
|
{
|
||||||
EmailTemplateEntity? entity = await EfRepository.Get()
|
ValidateLegacy(template);
|
||||||
.FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct);
|
var definition = await FindDefinitionAsync(template.ServiceName, template.Key, ct);
|
||||||
|
if (definition is null)
|
||||||
if (entity is null)
|
|
||||||
{
|
{
|
||||||
return;
|
await RequireServiceLanguageAsync(template.ServiceName, template.LanguageCode, ct);
|
||||||
|
definition = await CreateDefinitionAsync(template.ServiceName, template.Key, template.Variables, ct);
|
||||||
}
|
}
|
||||||
|
EnsureMatchingContract(definition, template.Variables);
|
||||||
|
await RequireServiceLanguageAsync(template.ServiceName, template.LanguageCode, ct);
|
||||||
|
var code = template.LanguageCode;
|
||||||
|
if (definition.Languages.Any(t => t.ServiceLanguage.Code == code))
|
||||||
|
throw new EmailTemplateWriteException("This language already exists for the template.");
|
||||||
|
var language = NewLanguage(template, definition);
|
||||||
|
definition.Languages.Add(language);
|
||||||
|
db.Templates.Add(language);
|
||||||
|
Touch(definition);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
entity.ServiceName = EmailTemplate.ServiceName;
|
public Task UpdateAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
|
||||||
entity.Key = EmailTemplate.Key;
|
{
|
||||||
entity.LanguageCode = EmailTemplate.LanguageCode;
|
ValidateLegacy(template);
|
||||||
entity.Subject = EmailTemplate.Subject;
|
var definition = await RequireDefinitionAsync(template.ServiceName, template.Key, ct);
|
||||||
entity.HtmlBody = EmailTemplate.HtmlBody;
|
EnsureMatchingContract(definition, template.Variables);
|
||||||
entity.TextBody = EmailTemplate.TextBody;
|
var language = definition.Languages.SingleOrDefault(t => t.Id == template.Id)
|
||||||
entity.Variables = EmailTemplate.Variables
|
?? throw new EmailTemplateWriteException("The language changed or was removed. Reload the template.");
|
||||||
.Select(v => new EmailTemplateVariableData { Name = v.Name, Required = v.Required })
|
SetContent(language, template);
|
||||||
.ToList();
|
Touch(definition);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
await EfRepository.SaveChangesAsync();
|
public Task DeleteAsync(EmailTemplate template, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
var definition = await RequireDefinitionAsync(template.ServiceName, template.Key, ct);
|
||||||
|
var language = definition.Languages.SingleOrDefault(t => t.Id == template.Id)
|
||||||
|
?? throw new EmailTemplateWriteException("The language changed or was removed. Reload the template.");
|
||||||
|
db.Templates.Remove(language);
|
||||||
|
Touch(definition);
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
public Task SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
EmailTemplateValidation.ValidateDefinition(definition);
|
||||||
|
await ValidateServiceLanguagesAsync(definition, ct);
|
||||||
|
var entity = await LoadForSaveAsync(definition, isNew, ct);
|
||||||
|
entity.Variables = MapVariables(definition.Variables);
|
||||||
|
SynchronizeLanguages(entity, definition.Languages);
|
||||||
|
Touch(entity);
|
||||||
|
definition.Id = entity.Id;
|
||||||
|
definition.Revision = entity.Revision;
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
private async Task<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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(EmailTemplate EmailTemplate, CancellationToken ct = default)
|
private void SynchronizeLanguages(EmailTemplateDefinitionEntity definition, IReadOnlyList<EmailTemplate> languages)
|
||||||
{
|
{
|
||||||
EmailTemplateEntity? entity = await EfRepository.Get()
|
var codes = languages.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||||
.FirstOrDefaultAsync(x => x.Id == EmailTemplate.Id, ct);
|
db.Templates.RemoveRange(definition.Languages.Where(t => !codes.Contains(t.ServiceLanguage.Code)));
|
||||||
if (entity is not null)
|
foreach (var model in languages)
|
||||||
await EfRepository.DeleteAsync(entity);
|
{
|
||||||
|
var code = model.LanguageCode;
|
||||||
|
var entity = definition.Languages.SingleOrDefault(t => t.ServiceLanguage.Code == code);
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
var language = NewLanguage(model, definition);
|
||||||
|
definition.Languages.Add(language);
|
||||||
|
db.Templates.Add(language);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
SetContent(entity, model);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static EmailTemplate MapToDomain(EmailTemplateEntity e) => new()
|
public async Task<IReadOnlyList<EmailTemplateDefinition>> GetDefinitionsAsync(string? serviceName = null, string? key = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Id = e.Id,
|
var query = Definitions.AsNoTracking();
|
||||||
ServiceName = e.ServiceName,
|
if (!string.IsNullOrWhiteSpace(serviceName))
|
||||||
Key = e.Key,
|
query = query.Where(d => d.Service.Name == serviceName);
|
||||||
LanguageCode = e.LanguageCode,
|
if (!string.IsNullOrWhiteSpace(key))
|
||||||
Subject = e.Subject,
|
query = query.Where(d => d.Key == key);
|
||||||
HtmlBody = e.HtmlBody,
|
var entities = await query.OrderBy(d => d.Service.Name).ThenBy(d => d.Key).ToListAsync(ct);
|
||||||
TextBody = e.TextBody,
|
return entities.Select(MapDefinition).ToList();
|
||||||
Variables = e.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(),
|
}
|
||||||
Created = e.Created,
|
|
||||||
Updated = e.Updated
|
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,188 @@
|
|||||||
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
|
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||||
|
using HrynCo.NotificationService.DAL.EF.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace HrynCo.NotificationService.DAL.EF.Repositories;
|
||||||
|
|
||||||
|
internal sealed class NotificationServiceRepository(NotificationDbContext db, TemplateMutationRunner mutations) : INotificationServiceRepository
|
||||||
|
{
|
||||||
|
public async Task<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 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)
|
||||||
|
|||||||
+81
-14
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -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>>;
|
||||||
|
|||||||
+11
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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>>;
|
||||||
+26
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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,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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
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 SaveDefinitionAsync(EmailTemplateDefinition definition, bool isNew, CancellationToken ct = default) =>
|
||||||
|
throw new IOException("Simulated save failure.");
|
||||||
|
}
|
||||||
+6
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -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,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 email templates found", 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 email templates found", 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 found", 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 email templates found", 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,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 < 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,125 @@
|
|||||||
|
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) : 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,178 @@
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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.Name, ct: 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-19
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
+28
@@ -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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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;
|
||||||
|
}
|
||||||
+12
@@ -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; }
|
||||||
|
}
|
||||||
+10
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -29,7 +29,8 @@ app.MapScalarApiReference(options =>
|
|||||||
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.MapGet("/", () => Results.Redirect("/admin/templates"));
|
app.MapGet("/", () => Results.Redirect("/admin/services"));
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapDefaultControllerRoute();
|
app.MapDefaultControllerRoute();
|
||||||
app.Run();
|
app.Run();
|
||||||
|
public partial class Program { }
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||||
|
@model ServiceLanguageDeleteViewModel
|
||||||
|
@{ ViewData["Title"] = "Delete service language and versions"; }
|
||||||
|
<h2>Delete language @Model.Code from @Model.ServiceName?</h2>
|
||||||
|
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
|
||||||
|
<div class="alert alert-danger">This permanently deletes <strong>@Model.VersionCount template version(s)</strong> using this language in this service and removes the language code. Template definitions, their keys and shared variables are retained.</div>
|
||||||
|
<form method="post" action="/admin/services/@Model.ServiceId/languages/@Model.LanguageId/delete">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input asp-for="VersionCount" type="hidden" /><input asp-for="Revision" type="hidden" />
|
||||||
|
<div class="form-check mb-3"><input asp-for="ConfirmDelete" class="form-check-input" /><label asp-for="ConfirmDelete" class="form-check-label">I confirm deletion of @Model.VersionCount version(s) and language @Model.Code.</label></div>
|
||||||
|
<button type="submit" class="btn btn-danger">Delete @Model.VersionCount version(s) and language</button>
|
||||||
|
<a class="btn btn-secondary" href="/admin/services/@Model.ServiceId/edit">Cancel</a>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||||
|
@model NotificationServiceEditViewModel
|
||||||
|
@{
|
||||||
|
Layout = "~/Views/Shared/_EditorLayout.cshtml";
|
||||||
|
ViewData["Title"] = Model.IsNew ? "Create Service" : "Edit Service";
|
||||||
|
ViewData["EditorTitle"] = ViewData["Title"];
|
||||||
|
}
|
||||||
|
@section Breadcrumbs {
|
||||||
|
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li>@if (!Model.IsNew) { <li class="breadcrumb-item"><a href="/admin/services/@Model.Id">@Model.Name</a></li> }<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li></ol></nav>
|
||||||
|
}
|
||||||
|
<form asp-action="Save" id="serviceForm" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<input asp-for="Id" type="hidden" /><input asp-for="Revision" type="hidden" />
|
||||||
|
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
||||||
|
<label asp-for="Name" class="form-label fw-semibold">Service name</label>
|
||||||
|
<input asp-for="Name" class="form-control mb-3" maxlength="100" />
|
||||||
|
<h3 class="h5">Language codes</h3>
|
||||||
|
<p class="text-muted">Codes are case-sensitive and belong to this service. Existing template versions use these codes when sending. Renaming a service or code changes sender lookup; align publishers and email channel service names.</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table service-language-table"><thead><tr><th>Code</th><th>Versions</th><th class="service-language-actions">Actions</th></tr></thead><tbody id="serviceLanguages">
|
||||||
|
@for (var i = 0; i < Model.Languages.Count; i++)
|
||||||
|
{
|
||||||
|
var countUnavailable = ViewData["LanguageCountsUnavailable"] is true && Model.Languages[i].Id != Guid.Empty;
|
||||||
|
<tr>
|
||||||
|
<td><input asp-for="Languages[i].Id" type="hidden" /><label for="service-code-@i" class="visually-hidden">Language code</label><input asp-for="Languages[i].Code" id="service-code-@i" class="form-control" maxlength="10" /></td>
|
||||||
|
<td>@(countUnavailable ? "Unavailable" : Model.Languages[i].VersionCount.ToString())</td>
|
||||||
|
<td class="service-language-actions">
|
||||||
|
<div class="service-language-action-buttons">
|
||||||
|
<button type="button" class="btn btn-outline-secondary remove-service-language" data-count="@Model.Languages[i].VersionCount" disabled="@countUnavailable">Remove</button>
|
||||||
|
@if (!Model.IsNew && Model.Languages[i].Id != Guid.Empty)
|
||||||
|
{
|
||||||
|
<a class="btn btn-outline-danger" href="/admin/services/@Model.Id/languages/@Model.Languages[i].Id/delete">Delete language and versions…</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="addServiceLanguage" class="btn btn-outline-primary">Add language code</button>
|
||||||
|
<div id="serviceLanguageAlert" role="alert" class="text-danger mt-3"></div>
|
||||||
|
</form>
|
||||||
|
<template id="serviceLanguageRow"><tr><td><input type="hidden" name="Languages[__index__].Id" value="00000000-0000-0000-0000-000000000000" /><label for="service-code-__index__" class="visually-hidden">Language code</label><input id="service-code-__index__" name="Languages[__index__].Code" class="form-control" maxlength="10" /></td><td>0</td><td class="service-language-actions"><div class="service-language-action-buttons"><button type="button" class="btn btn-outline-secondary remove-service-language" data-count="0">Remove</button></div></td></tr></template>
|
||||||
|
@section FormActions {
|
||||||
|
<button type="submit" form="serviceForm" class="btn btn-primary">Save service</button>
|
||||||
|
<a href="@(Model.IsNew ? "/admin/services" : $"/admin/services/{Model.Id}")" class="btn btn-secondary">Cancel</a>
|
||||||
|
}
|
||||||
|
@section Scripts { <script src="~/js/service-editor.js" asp-append-version="true"></script> }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@using HrynCo.NotificationService.DAL.Abstract.Templates
|
||||||
|
@model IReadOnlyList<NotificationServiceInfo>
|
||||||
|
@{ ViewData["Title"] = "Services"; }
|
||||||
|
<div class="page-header"><h2>Services</h2><a href="/admin/services/create" class="btn btn-primary">Create service</a></div>
|
||||||
|
@if (Model.Count == 0) { <p>No services yet. Create a service and its language codes before adding templates.</p> }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive"><table class="table table-hover"><thead><tr><th>Name</th><th>Language codes</th><th class="service-actions">Action</th></tr></thead><tbody>
|
||||||
|
@foreach (var service in Model)
|
||||||
|
{
|
||||||
|
<tr><td><a href="/admin/services/@service.Id">@service.Name</a></td><td>@string.Join(", ", service.Languages.Select(l => l.Code))</td><td class="service-actions"><a href="/admin/services/@service.Id/edit" class="btn btn-outline-primary btn-sm" aria-label="Edit service @service.Name">Edit</a></td></tr>
|
||||||
|
}
|
||||||
|
</tbody></table></div>
|
||||||
|
}
|
||||||
@@ -1,300 +1,102 @@
|
|||||||
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||||
|
|
||||||
@model EmailTemplateEditViewModel
|
@model EmailTemplateEditViewModel
|
||||||
@{
|
@{
|
||||||
Layout = "~/Views/Shared/_EditorLayout.cshtml";
|
Layout = "~/Views/Shared/_EditorLayout.cshtml";
|
||||||
ViewData["Title"] = Model.PageTitle;
|
ViewData["Title"] = Model.PageTitle;
|
||||||
ViewData["EditorTitle"] = Model.PageTitle;
|
ViewData["EditorTitle"] = Model.PageTitle;
|
||||||
}
|
|
||||||
|
|
||||||
<form id="templateForm" asp-action="Save" asp-controller="AdminTemplates" method="post">
|
var active = Math.Clamp(Model.ActiveLanguageIndex, 0, Math.Max(0, Model.Languages.Count - 1));
|
||||||
|
}
|
||||||
|
@section Breadcrumbs {
|
||||||
|
<nav aria-label="Breadcrumb" class="template-editor-breadcrumbs"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="/admin/services/@Model.ServiceId">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">@Model.PageTitle</li></ol></nav>
|
||||||
|
}
|
||||||
|
<form id="templateForm" asp-action="Save" asp-controller="AdminTemplates" asp-route-serviceId="@Model.ServiceId" method="post">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input asp-for="Id" type="hidden" />
|
<input asp-for="Id" type="hidden" />
|
||||||
<input type="hidden" name="IsNew" value="@Model.IsNew" />
|
<input asp-for="Revision" type="hidden" />
|
||||||
<input asp-for="ServiceNameFilter" type="hidden" />
|
<input asp-for="ServiceId" type="hidden" /><input asp-for="ServiceName" type="hidden" />
|
||||||
<input asp-for="KeyFilter" type="hidden" />
|
<input asp-for="KeyFilter" type="hidden" />
|
||||||
|
<input type="hidden" id="ActiveLanguageIndex" name="ActiveLanguageIndex" value="@active" />
|
||||||
@if (!ViewData.ModelState.IsValid)
|
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
||||||
{
|
<div class="row g-3 mb-3">
|
||||||
<div class="alert alert-danger mb-3">
|
<div class="col-md-6">
|
||||||
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
<dl class="template-service-context">
|
||||||
|
<dt>Service</dt>
|
||||||
|
<dd>@Model.ServiceName</dd>
|
||||||
|
</dl>
|
||||||
|
<div class="form-text"><a href="/admin/services/@Model.ServiceId/edit" class="template-service-edit-link">Edit Service and language codes</a></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label asp-for="Key" class="form-label fw-semibold">Template Key</label>
|
||||||
|
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label asp-for="VariablesJson" class="form-label fw-semibold">Shared variables (JSON)</label>
|
||||||
|
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="4"></textarea>
|
||||||
|
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-muted">Every configured service language has a tab. Leave all three content fields blank for an unused language; clear them to remove an existing version. Shared variables and the template remain.</p>
|
||||||
|
@if (Model.Languages.Count == 0) { <p>No service languages configured. You can save the key and shared variables now, then add language codes in Edit Service.</p> }
|
||||||
|
<div class="template-language-sheet mb-3" hidden="@(Model.Languages.Count == 0)">
|
||||||
|
<div class="nav nav-tabs" role="tablist" aria-label="Template languages" id="languageTabs">
|
||||||
|
@for (var i = 0; i < Model.Languages.Count; i++)
|
||||||
{
|
{
|
||||||
<div>@error.ErrorMessage</div>
|
<button type="button" role="tab" id="language-tab-@i" aria-controls="language-pane-@i"
|
||||||
|
aria-selected="@(i == active ? "true" : "false")" tabindex="@(i == active ? 0 : -1)"
|
||||||
|
class="nav-link @(i == active ? "active" : "")" data-pane="language-pane-@i">@Model.Languages[i].LanguageCode</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
<div id="languagePanes">
|
||||||
|
@for (var i = 0; i < Model.Languages.Count; i++)
|
||||||
<ul class="nav nav-tabs template-editor-tabs mb-3" id="templateEditorTabs" role="tablist">
|
{
|
||||||
<li class="nav-item" role="presentation">
|
<section class="language-pane" id="language-pane-@i" role="tabpanel" aria-labelledby="language-tab-@i" hidden="@(i != active)">
|
||||||
<button class="nav-link active" id="edit-tab" data-bs-toggle="tab" data-bs-target="#edit-pane" type="button" role="tab" aria-controls="edit-pane" aria-selected="true">
|
<input asp-for="Languages[i].LanguageCode" type="hidden" class="language-code" />
|
||||||
Edit
|
<div class="mb-3">
|
||||||
</button>
|
<label asp-for="Languages[i].Subject" class="form-label fw-semibold">Subject</label>
|
||||||
</li>
|
<input asp-for="Languages[i].Subject" class="form-control" data-content="subject" />
|
||||||
<li class="nav-item" role="presentation">
|
</div>
|
||||||
<button class="nav-link" id="preview-tab" data-bs-toggle="tab" data-bs-target="#preview-pane" type="button" role="tab" aria-controls="preview-pane" aria-selected="false">
|
<div class="mb-3">
|
||||||
Preview
|
<label asp-for="Languages[i].HtmlBody" class="form-label fw-semibold">HTML Body</label>
|
||||||
</button>
|
<textarea asp-for="Languages[i].HtmlBody" class="form-control font-monospace" rows="10" data-content="html"></textarea>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
<div class="mb-3">
|
||||||
|
<label asp-for="Languages[i].TextBody" class="form-label fw-semibold">Text Body</label>
|
||||||
<div class="tab-content">
|
<textarea asp-for="Languages[i].TextBody" class="form-control font-monospace" rows="5" data-content="text"></textarea>
|
||||||
<div class="tab-pane fade show active" id="edit-pane" role="tabpanel" aria-labelledby="edit-tab" tabindex="0">
|
</div>
|
||||||
<div class="row g-3 mb-3">
|
</section>
|
||||||
<div class="col-md-5">
|
}
|
||||||
<label asp-for="ServiceName" class="form-label fw-semibold">Service Name</label>
|
|
||||||
<input asp-for="ServiceName" class="form-control" readonly="@(!Model.IsNew)" />
|
|
||||||
<span asp-validation-for="ServiceName" class="text-danger small"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-5">
|
|
||||||
<label asp-for="Key" class="form-label fw-semibold">Key</label>
|
|
||||||
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew)" />
|
|
||||||
<span asp-validation-for="Key" class="text-danger small"></span>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label asp-for="LanguageCode" class="form-label fw-semibold">Language</label>
|
|
||||||
<input asp-for="LanguageCode" class="form-control" readonly="@(!Model.IsNew)" />
|
|
||||||
<span asp-validation-for="LanguageCode" class="text-danger small"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label asp-for="Subject" class="form-label fw-semibold">Subject</label>
|
|
||||||
<input asp-for="Subject" class="form-control" />
|
|
||||||
<span asp-validation-for="Subject" class="text-danger small"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label asp-for="HtmlBody" class="form-label fw-semibold">HTML Body</label>
|
|
||||||
<textarea asp-for="HtmlBody" class="form-control font-monospace" rows="10"></textarea>
|
|
||||||
<span asp-validation-for="HtmlBody" class="text-danger small"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label asp-for="TextBody" class="form-label fw-semibold">Text Body</label>
|
|
||||||
<textarea asp-for="TextBody" class="form-control font-monospace" rows="5"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label asp-for="VariablesJson" class="form-label fw-semibold">Variables (JSON)</label>
|
|
||||||
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="4"
|
|
||||||
placeholder='[{"name":"UserName","required":true}]'></textarea>
|
|
||||||
<span asp-validation-for="VariablesJson" class="text-danger small"></span>
|
|
||||||
<div class="form-text">JSON array of <code>{"name":"...", "required":true|false}</code></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="tab-pane fade" id="preview-pane" role="tabpanel" aria-labelledby="preview-tab" tabindex="0">
|
<div class="template-preview-panel mb-3">
|
||||||
<div class="template-preview-panel mb-3">
|
<div class="template-preview-panel-header">
|
||||||
<div class="template-preview-panel-header">
|
<div>
|
||||||
<div>
|
<div class="template-preview-title">Selected language preview: <span id="previewLanguage"></span></div>
|
||||||
<div class="template-preview-title">Preview</div>
|
<div class="template-preview-subtitle">Sample values use the shared contract. External content is blocked in this preview.</div>
|
||||||
<div class="template-preview-subtitle">Rendered with sample values from the variable list.</div>
|
</div>
|
||||||
</div>
|
<span id="previewStatus" class="badge text-bg-secondary" role="status">Ready</span>
|
||||||
<span id="previewStatus" class="badge text-bg-secondary">Ready</span>
|
</div>
|
||||||
</div>
|
<div class="template-preview-body">
|
||||||
|
<div class="template-preview-source template-preview-section-block">
|
||||||
<div class="template-preview-body">
|
<div class="template-preview-section-title">Sample values</div>
|
||||||
<div class="template-preview-source template-preview-section-block">
|
<div id="previewVariables" class="template-preview-variables"></div>
|
||||||
<div class="template-preview-section-title">Sample values</div>
|
</div>
|
||||||
<div id="previewVariables" class="template-preview-variables"></div>
|
<div class="template-preview-output">
|
||||||
<div class="form-text mt-2">Change these values to see the rendered output update immediately.</div>
|
<div class="template-preview-section-title">Rendered subject</div>
|
||||||
</div>
|
<div id="previewSubject" class="template-preview-subject"></div>
|
||||||
|
<div class="template-preview-section-title mt-3">Rendered HTML</div>
|
||||||
<div class="template-preview-output">
|
<iframe id="previewHtmlFrame" class="template-preview-frame" sandbox="" referrerpolicy="no-referrer" title="Email HTML preview"></iframe>
|
||||||
<div class="template-preview-section-title">Rendered subject</div>
|
<div class="template-preview-section-title mt-3">Rendered text</div>
|
||||||
<div id="previewSubject" class="template-preview-subject"></div>
|
<pre id="previewText" class="template-preview-text mb-0"></pre>
|
||||||
|
|
||||||
<div class="template-preview-section-title mt-3">Rendered HTML</div>
|
|
||||||
<iframe id="previewHtmlFrame" class="template-preview-frame" title="Email HTML preview"></iframe>
|
|
||||||
|
|
||||||
<div class="template-preview-section-title mt-3">Rendered text</div>
|
|
||||||
<pre id="previewText" class="template-preview-text mb-0"></pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@section FormActions {
|
|
||||||
<button type="submit" form="templateForm" class="btn btn-primary">
|
|
||||||
<i class="bi bi-floppy me-1"></i> Save
|
|
||||||
</button>
|
|
||||||
<a href="/admin/templates@(string.IsNullOrWhiteSpace(Model.ServiceNameFilter) && string.IsNullOrWhiteSpace(Model.KeyFilter) ? string.Empty : $"?serviceName={Uri.EscapeDataString(Model.ServiceNameFilter ?? string.Empty)}&key={Uri.EscapeDataString(Model.KeyFilter ?? string.Empty)}")" class="btn btn-secondary">
|
|
||||||
<i class="bi bi-x-lg me-1"></i> Cancel
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const subjectField = document.getElementById('Subject');
|
|
||||||
const htmlField = document.getElementById('HtmlBody');
|
|
||||||
const textField = document.getElementById('TextBody');
|
|
||||||
const variablesField = document.getElementById('VariablesJson');
|
|
||||||
const previewVariablesHost = document.getElementById('previewVariables');
|
|
||||||
const previewSubject = document.getElementById('previewSubject');
|
|
||||||
const previewText = document.getElementById('previewText');
|
|
||||||
const previewFrame = document.getElementById('previewHtmlFrame');
|
|
||||||
const previewStatus = document.getElementById('previewStatus');
|
|
||||||
const previewTab = document.getElementById('preview-tab');
|
|
||||||
|
|
||||||
if (!subjectField || !htmlField || !textField || !variablesField || !previewVariablesHost || !previewSubject || !previewText || !previewFrame || !previewStatus) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVariables() {
|
|
||||||
const raw = variablesField.value?.trim() || '[]';
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
|
|
||||||
if (!Array.isArray(parsed)) {
|
|
||||||
throw new Error('Variables JSON must be an array.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed
|
|
||||||
.filter(item => item && typeof item.Name === 'string' && item.Name.trim().length > 0)
|
|
||||||
.map(item => ({
|
|
||||||
name: item.Name.trim(),
|
|
||||||
required: !!item.Required
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSampleValue(name) {
|
|
||||||
return `Sample ${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderVariableInputs() {
|
|
||||||
let variables = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
variables = parseVariables();
|
|
||||||
previewStatus.className = 'badge text-bg-secondary';
|
|
||||||
previewStatus.textContent = 'Ready';
|
|
||||||
} catch (error) {
|
|
||||||
previewVariablesHost.innerHTML = `<div class="alert alert-warning mb-0">${escapeHtml(error.message || 'Invalid variables JSON')}</div>`;
|
|
||||||
previewStatus.className = 'badge text-bg-danger';
|
|
||||||
previewStatus.textContent = 'Invalid JSON';
|
|
||||||
updatePreview();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (variables.length === 0) {
|
|
||||||
previewVariablesHost.innerHTML = '<div class="text-muted small">No variables defined.</div>';
|
|
||||||
updatePreview();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentValues = readVariableValues();
|
|
||||||
previewVariablesHost.innerHTML = variables.map(variable => {
|
|
||||||
const value = Object.prototype.hasOwnProperty.call(currentValues, variable.name)
|
|
||||||
? currentValues[variable.name]
|
|
||||||
: buildSampleValue(variable.name);
|
|
||||||
return `
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small fw-semibold mb-1">${escapeHtml(variable.name)}${variable.required ? ' *' : ''}</label>
|
|
||||||
<input type="text" class="form-control form-control-sm preview-variable-input" data-variable-name="${escapeHtml(variable.name)}" value="${escapeHtml(value)}" />
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
|
|
||||||
input.addEventListener('input', updatePreview);
|
|
||||||
});
|
|
||||||
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
function readVariableValues() {
|
|
||||||
const values = {};
|
|
||||||
previewVariablesHost.querySelectorAll('.preview-variable-input').forEach(input => {
|
|
||||||
values[input.dataset.variableName] = input.value;
|
|
||||||
});
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
function interpolate(text, values) {
|
|
||||||
let result = text || '';
|
|
||||||
|
|
||||||
Object.keys(values).forEach(key => {
|
|
||||||
const token = new RegExp(`\\{\\{${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\}\\}`, 'g');
|
|
||||||
result = result.replace(token, values[key] ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePreview() {
|
|
||||||
let values = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
values = readVariableValues();
|
|
||||||
} catch (error) {
|
|
||||||
values = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderedSubject = interpolate(subjectField.value, values);
|
|
||||||
const renderedHtml = interpolate(htmlField.value, values);
|
|
||||||
const renderedText = interpolate(textField.value, values);
|
|
||||||
|
|
||||||
previewSubject.textContent = renderedSubject || '(empty subject)';
|
|
||||||
previewText.textContent = renderedText || '(empty text body)';
|
|
||||||
previewFrame.srcdoc = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 24px;
|
|
||||||
background: #eef2f7;
|
|
||||||
color: #1f2937;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
||||||
.email-shell {
|
|
||||||
max-width: 640px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #dbe3ee;
|
|
||||||
border-radius: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 12px 30px rgba(15, 23, 42, .08);
|
|
||||||
}
|
|
||||||
.email-body {
|
|
||||||
padding: 28px 36px;
|
|
||||||
}
|
|
||||||
img { max-width: 100%; height: auto; }
|
|
||||||
a { color: #2563eb; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="email-shell">
|
|
||||||
<div class="email-body">
|
|
||||||
${renderedHtml || '<div style="color:#6b7280">No HTML body provided.</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
variablesField.addEventListener('input', renderVariableInputs);
|
|
||||||
subjectField.addEventListener('input', updatePreview);
|
|
||||||
htmlField.addEventListener('input', updatePreview);
|
|
||||||
textField.addEventListener('input', updatePreview);
|
|
||||||
if (previewTab) {
|
|
||||||
previewTab.addEventListener('shown.bs.tab', updatePreview);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderVariableInputs();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
</form>
|
</form>
|
||||||
|
@section FormActions {
|
||||||
|
<button type="submit" form="templateForm" class="btn btn-primary"><i class="bi bi-floppy me-1"></i> Save</button>
|
||||||
|
<a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a>
|
||||||
|
}
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,172 +1,77 @@
|
|||||||
@using HrynCo.NotificationService.DAL.Abstract.Templates
|
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
|
||||||
@model IReadOnlyList<EmailTemplate>
|
@model ServiceTemplatesViewModel
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Email Templates";
|
ViewData["Title"] = Model.Service.Name;
|
||||||
var serviceNameFilter = ViewData["ServiceNameFilter"] as string ?? string.Empty;
|
var serviceId = Model.Service.Id;
|
||||||
var keyFilter = ViewData["KeyFilter"] as string ?? string.Empty;
|
var keyFilter = Model.KeyFilter;
|
||||||
var filterQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
|
|
||||||
? string.Empty
|
|
||||||
: $"?serviceNameFilter={Uri.EscapeDataString(serviceNameFilter)}&keyFilter={Uri.EscapeDataString(keyFilter)}";
|
|
||||||
var listQuery = string.IsNullOrWhiteSpace(serviceNameFilter) && string.IsNullOrWhiteSpace(keyFilter)
|
|
||||||
? string.Empty
|
|
||||||
: $"?serviceName={Uri.EscapeDataString(serviceNameFilter)}&key={Uri.EscapeDataString(keyFilter)}";
|
|
||||||
}
|
}
|
||||||
|
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item active" aria-current="page">@Model.Service.Name</li></ol></nav>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2><i class="bi bi-envelope-paper"></i> Email Templates</h2>
|
<h2>@Model.Service.Name</h2>
|
||||||
<a href="/admin/templates/create@(filterQuery)" class="btn btn-primary btn-sm">
|
<a href="/admin/services/@serviceId/edit" class="btn btn-outline-primary">Edit Service</a>
|
||||||
<i class="bi bi-plus-lg me-1"></i> Create New Template
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="service-template-catalog mb-3" aria-label="Configured service languages">
|
||||||
<div class="card shadow-sm mb-3">
|
<span class="fw-semibold">Configured languages:</span>
|
||||||
<div class="card-body">
|
@if (Model.Service.Languages.Count == 0)
|
||||||
<form id="templateFiltersForm" method="get" action="/admin/templates" class="row g-2 align-items-end">
|
{
|
||||||
<div class="col-12 col-md-5">
|
<span>No languages configured for this service.</span>
|
||||||
<label class="form-label fw-semibold" for="serviceName">Service Name</label>
|
}
|
||||||
<input id="serviceName"
|
else
|
||||||
name="serviceName"
|
{
|
||||||
value="@serviceNameFilter"
|
@foreach (var language in Model.Service.Languages)
|
||||||
class="form-control"
|
{
|
||||||
placeholder="Filter by service name" />
|
<span class="service-template-language-code">@language.Code</span>
|
||||||
</div>
|
}
|
||||||
<div class="col-12 col-md-5">
|
}
|
||||||
<label class="form-label fw-semibold" for="key">Key</label>
|
</div>
|
||||||
<input id="key"
|
<div class="page-header">
|
||||||
name="key"
|
<h3 class="h4">Email Templates</h3>
|
||||||
value="@keyFilter"
|
<a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a>
|
||||||
class="form-control"
|
</div>
|
||||||
placeholder="Filter by key" />
|
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
||||||
</div>
|
<div class="table-responsive"><table class="table table-hover service-template-grid"><thead><tr>
|
||||||
<div class="col-12 col-md-2 d-flex gap-2">
|
<th scope="col">
|
||||||
<button type="submit" class="btn btn-primary w-100">Filter</button>
|
<form method="get" action="/admin/services/@serviceId" class="template-key-filter">
|
||||||
<a id="clearTemplateFilters" href="/admin/templates" class="btn btn-outline-secondary w-100">Clear</a>
|
<label for="key" class="form-label fw-semibold">Template Key</label>
|
||||||
|
<div class="template-key-filter-controls">
|
||||||
|
<input id="key" name="key" value="@keyFilter" class="form-control form-control-sm" placeholder="Filter by key" />
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
|
||||||
|
<a href="/admin/services/@serviceId" class="btn btn-outline-secondary btn-sm">Clear</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</th>
|
||||||
</div>
|
<th scope="col">Languages</th>
|
||||||
|
<th scope="col" class="template-actions">Action</th>
|
||||||
@if (!ViewData.ModelState.IsValid)
|
</tr></thead><tbody>
|
||||||
|
@if (Model.Definitions.Count == 0)
|
||||||
{
|
{
|
||||||
<div class="alert alert-danger">
|
<tr><td colspan="3">No email templates found for this service.</td></tr>
|
||||||
@foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors))
|
|
||||||
{
|
|
||||||
<div>@error.ErrorMessage</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
@foreach (var template in Model.Definitions)
|
||||||
<script>
|
{
|
||||||
(() => {
|
var editUrl = Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = template.Id, keyFilter });
|
||||||
const storageKey = 'hrynco.notificationService.adminTemplates.filters';
|
var presentCodes = template.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||||
const form = document.getElementById('templateFiltersForm');
|
<tr>
|
||||||
const serviceNameInput = document.getElementById('serviceName');
|
<td>@template.Key</td>
|
||||||
const keyInput = document.getElementById('key');
|
<td>
|
||||||
const clearLink = document.getElementById('clearTemplateFilters');
|
@if (Model.Service.Languages.Count == 0)
|
||||||
|
{
|
||||||
if (!form || !serviceNameInput || !keyInput || !clearLink) {
|
<span>No configured languages</span>
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveState = () => {
|
|
||||||
const state = {
|
|
||||||
serviceName: serviceNameInput.value ?? '',
|
|
||||||
key: keyInput.value ?? ''
|
|
||||||
};
|
|
||||||
|
|
||||||
localStorage.setItem(storageKey, JSON.stringify(state));
|
|
||||||
};
|
|
||||||
|
|
||||||
const restoreState = () => {
|
|
||||||
const raw = localStorage.getItem(storageKey);
|
|
||||||
if (!raw) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
else
|
||||||
try {
|
{
|
||||||
const state = JSON.parse(raw);
|
<ul class="template-language-statuses">
|
||||||
const serviceName = typeof state.serviceName === 'string' ? state.serviceName : '';
|
@foreach (var language in Model.Service.Languages)
|
||||||
const key = typeof state.key === 'string' ? state.key : '';
|
{
|
||||||
|
var present = presentCodes.Contains(language.Code);
|
||||||
serviceNameInput.value = serviceName;
|
<li class="template-language-status @(present ? "template-language-present" : "template-language-missing")" data-language-code="@language.Code" data-state="@(present ? "present" : "missing")">
|
||||||
keyInput.value = key;
|
<span>@language.Code:</span><span>@(present ? "Present" : "Missing")</span>
|
||||||
|
</li>
|
||||||
return serviceName.length > 0 || key.length > 0;
|
}
|
||||||
} catch {
|
</ul>
|
||||||
localStorage.removeItem(storageKey);
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
};
|
</td>
|
||||||
|
<td class="template-actions"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @template.Key">Edit</a></td>
|
||||||
form.addEventListener('submit', saveState);
|
</tr>
|
||||||
clearLink.addEventListener('click', () => localStorage.removeItem(storageKey));
|
|
||||||
|
|
||||||
const hasQueryParams = new URLSearchParams(window.location.search).toString().length > 0;
|
|
||||||
if (!hasQueryParams && restoreState()) {
|
|
||||||
form.requestSubmit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveState();
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
@if (Model is null || Model.Count == 0)
|
|
||||||
{
|
|
||||||
<div class="card shadow-sm table-card">
|
|
||||||
<div class="empty-state">
|
|
||||||
<i class="bi bi-envelope-paper"></i>
|
|
||||||
<p class="mt-2 mb-0">No email templates found.</p>
|
|
||||||
<a href="/admin/templates/create" class="btn btn-primary btn-sm mt-3">
|
|
||||||
<i class="bi bi-plus-lg me-1"></i> Create First Template
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="card shadow-sm table-card">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover table-sm mb-0">
|
|
||||||
<thead class="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th>Service Name</th>
|
|
||||||
<th>Key</th>
|
|
||||||
<th>Language</th>
|
|
||||||
<th>Subject</th>
|
|
||||||
<th class="text-end">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var t in Model)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@t.ServiceName</td>
|
|
||||||
<td>@t.Key</td>
|
|
||||||
<td>@t.LanguageCode</td>
|
|
||||||
<td>@t.Subject</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<a href="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode@(filterQuery)"
|
|
||||||
class="btn btn-sm btn-outline-primary me-1">
|
|
||||||
<i class="bi bi-pencil"></i> Edit
|
|
||||||
</a>
|
|
||||||
<form method="post"
|
|
||||||
action="/admin/templates/@t.ServiceName/@t.Key/@t.LanguageCode/delete"
|
|
||||||
class="d-inline">
|
|
||||||
<input type="hidden" name="serviceNameFilter" value="@serviceNameFilter" />
|
|
||||||
<input type="hidden" name="keyFilter" value="@keyFilter" />
|
|
||||||
@Html.AntiForgeryToken()
|
|
||||||
<button type="submit"
|
|
||||||
class="btn btn-sm btn-outline-danger"
|
|
||||||
onclick="return confirm('Delete this template?')">
|
|
||||||
<i class="bi bi-trash"></i> Delete
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
</tbody></table></div>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
}
|
}
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
|
@RenderSection("Breadcrumbs", required: false)
|
||||||
<div class="card editor-card">
|
<div class="card editor-card">
|
||||||
<div class="card-header editor-card-header-bar">
|
<div class="card-header editor-card-header-bar">
|
||||||
<i class="bi bi-pencil-square me-2"></i>@ViewData["EditorTitle"]
|
<h1 class="editor-card-title"><i class="bi bi-pencil-square" aria-hidden="true"></i><span>@ViewData["EditorTitle"]</span></h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body editor-card-body">
|
<div class="card-body editor-card-body">
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@{
|
@{
|
||||||
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? "";
|
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? "";
|
||||||
bool isTemplates = currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
|
bool isServices = currentController.Equals("AdminServices", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
|
||||||
bool isChannels = currentController.Equals("AdminChannels", StringComparison.OrdinalIgnoreCase);
|
bool isChannels = currentController.Equals("AdminChannels", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -26,11 +27,7 @@
|
|||||||
<nav class="col-md-2 d-none d-md-block sidebar py-3">
|
<nav class="col-md-2 d-none d-md-block sidebar py-3">
|
||||||
<div class="sidebar-section-label">Management</div>
|
<div class="sidebar-section-label">Management</div>
|
||||||
<ul class="nav flex-column px-2">
|
<ul class="nav flex-column px-2">
|
||||||
<li class="nav-item">
|
<li class="nav-item"><a class="nav-link @(isServices ? "active" : "")" href="/admin/services"><i class="bi bi-collection"></i> Services</a></li>
|
||||||
<a class="nav-link @(isTemplates ? "active" : "")" href="/admin/templates">
|
|
||||||
<i class="bi bi-envelope-paper"></i> Email Templates
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link @(isChannels ? "active" : "")" href="/admin/channels">
|
<a class="nav-link @(isChannels ? "active" : "")" href="/admin/channels">
|
||||||
<i class="bi bi-broadcast"></i> Email Channels
|
<i class="bi bi-broadcast"></i> Email Channels
|
||||||
|
|||||||
@@ -109,6 +109,120 @@ body {
|
|||||||
.table tbody tr td { vertical-align: middle; }
|
.table tbody tr td { vertical-align: middle; }
|
||||||
.table thead th { font-size: .75rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
|
.table thead th { font-size: .75rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; }
|
||||||
|
|
||||||
|
/* ── List actions ─────────────────────────────────────── */
|
||||||
|
.table .template-actions,
|
||||||
|
.table .service-actions {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Service template catalog and filters ─────────────── */
|
||||||
|
.service-template-grid > thead > tr > th {
|
||||||
|
--bs-table-bg: #dce7f6;
|
||||||
|
--bs-table-color: #243a55;
|
||||||
|
border-bottom: 2px solid #aec0db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-template-grid .template-key-filter .btn-outline-secondary {
|
||||||
|
--bs-btn-bg: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-template-catalog,
|
||||||
|
.template-language-statuses {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: .375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-template-language-code {
|
||||||
|
padding: .15rem .4rem;
|
||||||
|
border: 1px solid #dce3eb;
|
||||||
|
border-radius: .25rem;
|
||||||
|
background: #fff;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-key-filter {
|
||||||
|
min-width: 9rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: normal;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-key-filter-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-key-filter-controls .form-control {
|
||||||
|
flex: 1 1 9rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-statuses {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-status {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .2rem .4rem;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: .25rem;
|
||||||
|
font-size: .75rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-present {
|
||||||
|
color: #146c43;
|
||||||
|
background: #e8f5ec;
|
||||||
|
border-color: #b7dcc0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-missing {
|
||||||
|
color: #825800;
|
||||||
|
background: #fff4df;
|
||||||
|
border-color: #f3d19e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Service language actions ─────────────────────────── */
|
||||||
|
.service-language-table .service-language-actions {
|
||||||
|
width: 1%;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-language-table .service-language-action-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-language-table .service-language-action-buttons > .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-language-table .form-control {
|
||||||
|
min-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575.98px) {
|
||||||
|
.service-language-table .service-language-action-buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-language-table .service-language-action-buttons > .btn {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Empty state ──────────────────────────────────────── */
|
/* ── Empty state ──────────────────────────────────────── */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -134,9 +248,7 @@ body {
|
|||||||
.editor-card-header-bar {
|
.editor-card-header-bar {
|
||||||
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
|
background: linear-gradient(135deg, #0d6efd 0%, #0a58ca 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: .9rem;
|
padding: 1rem 1.25rem;
|
||||||
font-weight: 600;
|
|
||||||
padding: .75rem 1.25rem;
|
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -145,6 +257,20 @@ body {
|
|||||||
|
|
||||||
.editor-card-body { padding: 1.5rem 1.5rem 1rem; }
|
.editor-card-body { padding: 1.5rem 1.5rem 1rem; }
|
||||||
|
|
||||||
|
.editor-card-header-bar .editor-card-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .625rem;
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-card-title > .bi { flex-shrink: 0; }
|
||||||
|
.editor-card-title > span { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
.editor-card-footer {
|
.editor-card-footer {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
border-top: 1px solid #e9ecef;
|
border-top: 1px solid #e9ecef;
|
||||||
@@ -175,6 +301,82 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Template service context ─────────────────────────── */
|
||||||
|
.template-editor-breadcrumbs .breadcrumb-item {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-service-context {
|
||||||
|
margin: 0 0 .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-service-context > dt {
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-service-context > dd {
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-service-edit-link:focus-visible {
|
||||||
|
outline: 2px solid #0d6efd;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Template language sheet ──────────────────────────── */
|
||||||
|
.template-language-sheet {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #dce3eb;
|
||||||
|
border-radius: .5rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-sheet > .nav-tabs {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: .25rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: .5rem .5rem 0;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: .5rem .5rem 0 0;
|
||||||
|
background: #f3f6fb;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-sheet > .nav-tabs .nav-link {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-sheet > .nav-tabs .nav-link.active {
|
||||||
|
border-color: #dce3eb #dce3eb #fff;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-sheet > .nav-tabs .nav-link:focus-visible {
|
||||||
|
outline: 2px solid #0d6efd;
|
||||||
|
outline-offset: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-sheet > #languagePanes {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-language-sheet .language-pane > :last-child {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Email template preview ───────────────────────────── */
|
/* ── Email template preview ───────────────────────────── */
|
||||||
.template-preview-panel {
|
.template-preview-panel {
|
||||||
border: 1px solid #dce3eb;
|
border: 1px solid #dce3eb;
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
(() => {
|
||||||
|
const form = document.getElementById('templateForm');
|
||||||
|
const tabs = document.getElementById('languageTabs');
|
||||||
|
const panes = document.getElementById('languagePanes');
|
||||||
|
const activeField = document.getElementById('ActiveLanguageIndex');
|
||||||
|
const variablesField = document.getElementById('VariablesJson');
|
||||||
|
const samples = document.getElementById('previewVariables');
|
||||||
|
let dirty = false;
|
||||||
|
const sampleValues = new Map();
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value).replaceAll('&', '&').replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function activePane() {
|
||||||
|
return panes.querySelector('.language-pane:not([hidden])');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPane(pane, focus = false) {
|
||||||
|
Array.from(panes.children).forEach(item => { item.hidden = item !== pane; });
|
||||||
|
Array.from(tabs.children).forEach(tab => {
|
||||||
|
const selected = tab.dataset.pane === pane.id;
|
||||||
|
tab.classList.toggle('active', selected);
|
||||||
|
tab.setAttribute('aria-selected', String(selected));
|
||||||
|
tab.tabIndex = selected ? 0 : -1;
|
||||||
|
if (selected && focus) tab.focus();
|
||||||
|
});
|
||||||
|
activeField.value = Array.from(panes.children).indexOf(pane);
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVariables() {
|
||||||
|
const variables = JSON.parse(variablesField.value);
|
||||||
|
if (!Array.isArray(variables)) throw new Error('Variables must be an array.');
|
||||||
|
const names = new Set();
|
||||||
|
variables.forEach(variable => {
|
||||||
|
if (!variable || typeof variable.name !== 'string' || !variable.name.trim() || typeof variable.required !== 'boolean')
|
||||||
|
throw new Error('Every variable needs a name and a boolean required property.');
|
||||||
|
if (Object.keys(variable).some(key => !['name', 'required'].includes(key)))
|
||||||
|
throw new Error('Use only name and required properties for each variable.');
|
||||||
|
if (names.has(variable.name)) throw new Error('Variable names must be unique.');
|
||||||
|
names.add(variable.name);
|
||||||
|
});
|
||||||
|
return variables;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSamples() {
|
||||||
|
const status = document.getElementById('previewStatus');
|
||||||
|
samples.replaceChildren();
|
||||||
|
try {
|
||||||
|
const variables = readVariables();
|
||||||
|
variables.forEach((variable, index) => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'mb-2';
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.htmlFor = `sample-${index}`;
|
||||||
|
label.className = 'form-label';
|
||||||
|
label.textContent = variable.name + (variable.required ? ' *' : '');
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.id = label.htmlFor;
|
||||||
|
input.dataset.variableName = variable.name;
|
||||||
|
input.className = 'form-control form-control-sm';
|
||||||
|
input.value = sampleValues.get(variable.name) ?? `Sample ${variable.name}`;
|
||||||
|
sampleValues.set(variable.name, input.value);
|
||||||
|
input.addEventListener('input', () => { sampleValues.set(variable.name, input.value); updatePreview(); });
|
||||||
|
wrapper.append(label, input);
|
||||||
|
samples.append(wrapper);
|
||||||
|
});
|
||||||
|
status.textContent = 'Ready';
|
||||||
|
status.className = 'badge text-bg-secondary';
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error.message;
|
||||||
|
status.className = 'badge text-bg-danger';
|
||||||
|
}
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(text, html = false) {
|
||||||
|
let result = text;
|
||||||
|
samples.querySelectorAll('input').forEach(input => {
|
||||||
|
const name = input.dataset.variableName;
|
||||||
|
const value = html ? escapeHtml(input.value) : input.value;
|
||||||
|
result = result.split(`{{${name}}}`).join(value);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const pane = activePane();
|
||||||
|
if (!pane) {
|
||||||
|
document.getElementById('previewLanguage').textContent = '(no versions)';
|
||||||
|
document.getElementById('previewSubject').textContent = '';
|
||||||
|
document.getElementById('previewText').textContent = '';
|
||||||
|
document.getElementById('previewHtmlFrame').srcdoc = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('previewLanguage').textContent = pane.querySelector('.language-code').value;
|
||||||
|
document.getElementById('previewSubject').textContent = interpolate(pane.querySelector('[data-content="subject"]').value);
|
||||||
|
document.getElementById('previewText').textContent = interpolate(pane.querySelector('[data-content="text"]').value);
|
||||||
|
const html = interpolate(pane.querySelector('[data-content="html"]').value, true);
|
||||||
|
document.getElementById('previewHtmlFrame').srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>body{font-family:Arial,sans-serif;padding:20px;color:#1f2937;background:white}img{max-width:100%}</style></head><body>${html}</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.addEventListener('click', event => {
|
||||||
|
const tab = event.target.closest('[role="tab"]');
|
||||||
|
if (tab) selectPane(document.getElementById(tab.dataset.pane));
|
||||||
|
});
|
||||||
|
tabs.addEventListener('keydown', event => {
|
||||||
|
const offsets = { ArrowLeft: -1, ArrowRight: 1, Home: -tabs.children.length, End: tabs.children.length };
|
||||||
|
if (!(event.key in offsets)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const buttons = Array.from(tabs.children);
|
||||||
|
if (!buttons.length) return;
|
||||||
|
let index = buttons.indexOf(event.target) + offsets[event.key];
|
||||||
|
if (event.key === 'Home') index = 0;
|
||||||
|
if (event.key === 'End') index = buttons.length - 1;
|
||||||
|
const tab = buttons[(index + buttons.length) % buttons.length];
|
||||||
|
selectPane(document.getElementById(tab.dataset.pane), true);
|
||||||
|
});
|
||||||
|
panes.addEventListener('input', updatePreview);
|
||||||
|
variablesField.addEventListener('input', renderSamples);
|
||||||
|
form.addEventListener('input', () => { dirty = true; });
|
||||||
|
form.addEventListener('submit', () => { dirty = false; });
|
||||||
|
window.addEventListener('beforeunload', event => {
|
||||||
|
if (dirty) { event.preventDefault(); event.returnValue = ''; }
|
||||||
|
});
|
||||||
|
|
||||||
|
renderSamples();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
(() => {
|
||||||
|
const form = document.getElementById('serviceForm');
|
||||||
|
const rows = document.getElementById('serviceLanguages');
|
||||||
|
const alert = document.getElementById('serviceLanguageAlert');
|
||||||
|
let sequence = rows.children.length;
|
||||||
|
let dirty = false;
|
||||||
|
function reindex() {
|
||||||
|
Array.from(rows.children).forEach((row, index) => row.querySelectorAll('[name]').forEach(input => {
|
||||||
|
input.name = input.name.replace(/Languages\[\d+\]/, `Languages[${index}]`);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
document.getElementById('addServiceLanguage').addEventListener('click', () => {
|
||||||
|
const fragment = document.createElement('template');
|
||||||
|
fragment.innerHTML = document.getElementById('serviceLanguageRow').innerHTML.replaceAll('__index__', String(sequence++));
|
||||||
|
rows.append(fragment.content);
|
||||||
|
reindex();
|
||||||
|
rows.lastElementChild.querySelector('input:not([type="hidden"])').focus();
|
||||||
|
dirty = true;
|
||||||
|
});
|
||||||
|
rows.addEventListener('click', event => {
|
||||||
|
const button = event.target.closest('.remove-service-language');
|
||||||
|
if (!button) return;
|
||||||
|
if (Number(button.dataset.count) > 0) {
|
||||||
|
alert.textContent = `Cannot remove this language: ${button.dataset.count} version(s) use it. Save other changes, then use the separate delete language and versions confirmation.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
button.closest('tr').remove();
|
||||||
|
reindex();
|
||||||
|
dirty = true;
|
||||||
|
});
|
||||||
|
form.addEventListener('input', () => { dirty = true; });
|
||||||
|
form.addEventListener('submit', () => { dirty = false; });
|
||||||
|
window.addEventListener('beforeunload', event => {
|
||||||
|
if (dirty) { event.preventDefault(); event.returnValue = ''; }
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -21,15 +21,21 @@ internal sealed class EmailTemplateService : IEmailTemplateService
|
|||||||
if (string.IsNullOrWhiteSpace(languageCode))
|
if (string.IsNullOrWhiteSpace(languageCode))
|
||||||
throw new InvalidDataException("LanguageCode is required.");
|
throw new InvalidDataException("LanguageCode is required.");
|
||||||
|
|
||||||
string lang = languageCode.Trim().ToLowerInvariant();
|
|
||||||
EmailTemplate? template = await _templateRepository.GetAsync(
|
EmailTemplate? template = await _templateRepository.GetAsync(
|
||||||
serviceName,
|
serviceName,
|
||||||
templateKey,
|
templateKey,
|
||||||
lang,
|
languageCode,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
if (template is null)
|
||||||
|
{
|
||||||
|
string compatibilityCode = languageCode.Trim().ToLowerInvariant();
|
||||||
|
if (!string.Equals(languageCode, compatibilityCode, StringComparison.Ordinal))
|
||||||
|
template = await _templateRepository.GetAsync(serviceName, templateKey, compatibilityCode, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return template
|
return template
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"Template not found: service='{serviceName}' key='{templateKey}' language='{lang}'.");
|
$"Template not found: service='{serviceName}' key='{templateKey}' language='{languageCode}'.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,21 @@ Production image references now use `registry.hrynco.com`, preserving the existi
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
- [Email template definitions and service languages](docs/email-template-localizations.md)
|
||||||
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
|
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
|
||||||
- [Shared Notification Service Kubernetes package](docker/kubernetes/shared-notification/README.md)
|
- [Shared Notification Service Kubernetes package](docker/kubernetes/shared-notification/README.md)
|
||||||
|
|
||||||
|
## Windows host development
|
||||||
|
|
||||||
|
For the owner's host development workflow, run Web in Rider with the Development
|
||||||
|
profile (`localhost:5433` PostgreSQL, HTTP `localhost:5188`) and keep Docker Web
|
||||||
|
stopped. Infrastructure can remain in the existing Docker stack. Web does not apply
|
||||||
|
schema migrations automatically. The owner's local database is prepared only when
|
||||||
|
they choose to run the current Migrator with an explicit connection setting.
|
||||||
|
See [template migration and local setup](docs/email-template-localizations.md) for
|
||||||
|
backup, conflict, compatibility and validation details. The full Docker installation
|
||||||
|
below is a separate complete-stack workflow.
|
||||||
|
|
||||||
## Development environment
|
## Development environment
|
||||||
|
|
||||||
The development Docker Compose stack runs PostgreSQL, RabbitMQ, database migrations,
|
The development Docker Compose stack runs PostgreSQL, RabbitMQ, database migrations,
|
||||||
@@ -58,7 +70,7 @@ docker compose --env-file docker/environments/.env.local `
|
|||||||
After startup, the main development endpoints are:
|
After startup, the main development endpoints are:
|
||||||
|
|
||||||
- Notification Service admin: `http://localhost:5200/admin/channels`
|
- Notification Service admin: `http://localhost:5200/admin/channels`
|
||||||
- Notification templates: `http://localhost:5200/admin/templates`
|
- Services and their notification templates: `http://localhost:5200/admin/services`
|
||||||
- RabbitMQ management: `http://localhost:15672` (`guest` / `guest`)
|
- RabbitMQ management: `http://localhost:15672` (`guest` / `guest`)
|
||||||
- Mailpit inbox: `http://localhost:8025`
|
- Mailpit inbox: `http://localhost:8025`
|
||||||
- Seq logs: `http://localhost:5342`
|
- Seq logs: `http://localhost:5342`
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Email template definitions and service languages — IT-1601
|
||||||
|
|
||||||
|
This is internal administration for the independently released HrynCo Notification Service. It is excluded from public Invemory release notes. The owner approved the four-entity design and empty definitions on 2026-10-17.
|
||||||
|
|
||||||
|
## Storage and invariants
|
||||||
|
|
||||||
|
- `services`: ID, unique exact name, revision and timestamps.
|
||||||
|
- `service_languages`: ID, service ID and exact code, unique `(service_id, code)`.
|
||||||
|
- `email_template_definitions`: ID, service ID, key, one shared variables JSON array, revision and timestamps; unique `(service_id, key)`.
|
||||||
|
- `email_templates`: existing language-version ID, definition ID, service ID, service-language ID, subject, HTML body, text body and timestamps; unique `(definition_id, service_language_id)`.
|
||||||
|
|
||||||
|
Composite foreign keys from a version's `(definition_id, service_id)` and `(service_language_id, service_id)` enforce that its language and definition belong to the same service. Service and service-language deletion are restricted by foreign keys; deleting a definition cascades to its versions. The administration does not expose service/definition deletion. Removing a version never removes its definition. Zero-version definitions retain their key, ID and shared variables.
|
||||||
|
|
||||||
|
Codes are case-sensitive exact identifiers. `ru`, `ru-RU`, `en` and `EN` remain distinct. Repository and API reads use exact codes without trimming, case folding or locale substitution. The worker first requests the exact code; only after a miss does it retry `LanguageCode.Trim().ToLowerInvariant()` when that string differs ordinally. It never scans case-insensitively, substitutes another region/language or modifies stored codes. New/edited catalog codes allow letters, numbers and hyphens, up to ten characters, with no blank code or empty hyphen segment. This is syntax validation, not a supported-language allowlist. Migration retains existing nonblank codes exactly. Unchanged migrated identifiers such as `en_US` remain valid during service edits; stricter syntax validation applies only to newly added or actually renamed code rows.
|
||||||
|
|
||||||
|
## Administration
|
||||||
|
|
||||||
|
`/admin/services` lists services. Its create/edit form manages Name and an inline table of language codes. Successful creation or editing, including a rename, returns to `/admin/services/{serviceId}` and its template list; validation/save errors retain the service form and entered values. The service page's **Edit Service** action uses the primary blue outline style. There is no separate language editor. Existing code rows retain their IDs when renamed. A transaction-local staging step frees old codes before final assignment so valid code swaps and replacements respect uniqueness without changing version identities. Intermediate codes are never committed or visible to other transactions. Renaming a service or code changes the identifiers used by template API clients and senders; publishers and independently configured email-channel service names must be aligned by the administrator.
|
||||||
|
|
||||||
|
Ordinary removal of an unused language row is saved with the service. For a used language, the form displays its affected version count and blocks ordinary removal. The server repeats this check using the current database count. After a validation or save error, usage counts are reloaded by persisted language ID while preserving entered names/codes and the original revision. If counts cannot be loaded, their display is unavailable and ordinary removal stays disabled.
|
||||||
|
|
||||||
|
The language table's Actions column is sized to its controls and aligned right for existing and newly added rows; buttons share a row on desktop and stack with complete labels on narrow screens, with table scrolling contained inside the form when needed.
|
||||||
|
|
||||||
|
The separate **Delete language and versions…** confirmation shows the current service, code and affected version count. It requires an explicit checkbox and POST with antiforgery protection. The server checks the current count and service revision under the mutation lock. A changed count/revision rejects the request, refreshes the displayed count and requires another confirmation. A confirmed deletion atomically removes every version using that service language and the catalog row, preserving every definition and its shared variables. Failure rolls the transaction back.
|
||||||
|
|
||||||
|
The administration starts at `/admin/services` (also the root redirect). A service name opens `/admin/services/{serviceId}`, which lists only that service's Email Templates, including definitions without translations. **Edit Service** is a separate action at `/admin/services/{id}/edit` for Name and the inline language table. There is no independent global Email Templates menu or extra page for a key: Template Key identifies the template edited directly from this service list. Create/edit/save/delete routes live under `/admin/services/{serviceId}/templates`; breadcrumbs, cancel and successful saves/deletes return to the selected service and retain its exact Key filter. Route IDs are bound explicitly from the route and cannot be replaced by form/query values. GET/POST validate definition ownership, and the mutation transaction also checks the selected service ID. Legacy global GET links redirect to the canonical service/template when identifiable, otherwise to Services; old global template POST routes are not retained. The language-specific API is unchanged.
|
||||||
|
|
||||||
|
The service's exact configured language codes appear near its heading. Each template row lists every catalog language with a text-and-color **Present** or **Missing** status, comparing exact ordinal codes (`en` and `EN`, or `ru` and `ru-RU`, remain distinct). A definition with no versions shows all configured languages as Missing; a service with no catalog has an explicit no-languages state. Status display never creates translations. The GET Key filter, Filter and Clear controls live in the Template Key column header, which stays visible for an empty service or filter miss; the scoped header strip has a contrasting blue background. Clear returns to the same service without a key filter, while create/edit/save/cancel retain the selected key filter.
|
||||||
|
|
||||||
|
A template belongs to the selected service; its create/edit form shows Template Key and a tab for **every** language configured in that service. Languages are added, renamed and removed only through Edit Service. A tab with Subject, HTML Body and Text Body all blank does not persist a version; clearing all three removes an existing version while retaining the definition. A partially populated tab requires all three fields and prevents the entire save if incomplete. All tabs may be blank, and a service with no configured languages may still have a key/shared-variables definition. Submitted tabs must match the current service catalog exactly, including uniqueness. Tabs retain unsaved content and support Left/Right/Home/End keyboard navigation. Shared variables appear once as a camelCase JSON array: `[{"name":"RecipientName","required":true}]`. Each variable has a unique nonblank name and a boolean `required`; unknown JSON properties and malformed shapes are rejected. Variable contracts compare Name and Required semantically, independent of variable order.
|
||||||
|
|
||||||
|
The language tabs and their Subject/HTML/Text fields form one bordered sheet, with horizontal tab scrolling on narrow screens; Template Key, shared variables and the existing preview remain outside it, and no empty sheet is shown when the service has no languages.
|
||||||
|
|
||||||
|
Service and template editor breadcrumbs appear above the editor card, followed by a prominent semantic page heading in its blue header. The shared editor layout keeps breadcrumbs optional so the channel editor continues to work without a breadcrumb section. In the template editor, the selected Service is plain semantic context with a prominent wrapping name beside Template Key; its smaller Edit Service and language codes link remains a separate keyboard-focusable action. Long service names wrap in both the context and template breadcrumb. The hidden service identity and routes are unchanged.
|
||||||
|
|
||||||
|
Preview uses the selected version and sample values from the shared contract. HTML variables are encoded as in the worker, while subject and text stay plain. The iframe is sandboxed without permissions and has a restrictive CSP; scripts, forms, top navigation and external resources are blocked. Save errors preserve entered content and active language; navigation warns about unsaved edits.
|
||||||
|
|
||||||
|
Every template/service mutation takes the same PostgreSQL transaction advisory lock (`1601`). This makes catalog membership, affected counts and uniqueness checks consistent across API and administration writers. Group and service editors also compare revision tokens, rejecting stale edits rather than overwriting changes. The lock serializes infrequent administration writes; reads and email sends do not take it.
|
||||||
|
|
||||||
|
The existing access boundary is preserved: there was no application-level authentication/authorization middleware or controller policy in this repository before this task, and none is introduced or removed here. Operate the administration/API behind the same restricted network/proxy boundary; no new permission role or public endpoint policy is added.
|
||||||
|
|
||||||
|
## Existing language-specific API
|
||||||
|
|
||||||
|
The routes under `/api/v1/email-templates` and their camelCase response envelopes are retained. The public `EmailTemplate` DTO remains a compatibility projection with service name, key, language code, language content, shared variables, existing version ID and timestamps.
|
||||||
|
|
||||||
|
| Operation | Mapping |
|
||||||
|
| --- | --- |
|
||||||
|
| GET collection / service filter / key filter | Flat language-version collection, as before. Empty definitions do not create synthetic language rows. |
|
||||||
|
| GET `/{service}/{key}/{code}` | Exact version content and the parent's current shared variables; missing version returns 404. |
|
||||||
|
| POST | Adds the version to an existing definition, or creates the definition for an existing service/catalog language. Missing service or unknown catalog code returns 400; it is never silently created. Duplicate version returns 409. |
|
||||||
|
| PUT `/{service}/{key}/{code}` | Updates only that version's content. It must submit the same semantic shared variable contract. Missing version returns 404. |
|
||||||
|
| POST/PUT with different shared variables | 409 with instructions to edit the shared contract in the logical template editor. No partial changes. |
|
||||||
|
| DELETE `/{service}/{key}/{code}` | Removes only the requested version, including the last one; definition/key/variables survive. Missing version returns 404. |
|
||||||
|
|
||||||
|
Legacy PUT requests have no revision field and retain last-successful-write behavior for language content. Group edits use revision tokens. Every successful legacy mutation advances the definition revision, so an already-open group editor cannot overwrite it silently. A changed shared contract makes an old legacy payload conflict.
|
||||||
|
|
||||||
|
The worker still uses the selected language's subject/HTML/text and validates required variables from the shared parent contract. The message envelope, SMTP/channel selection, quotas, retries and ACK/NACK behavior are unchanged. Exact stored versions take priority, so a request for `RU` selects `RU` when both `RU` and `ru` exist. If the exact version is absent, one differing trimmed/lowercase code is tried for compatibility with existing senders (`RU` or ` ru ` can resolve `ru`). A missing `RU-ru` may retry `ru-ru`; it does not search for `ru-RU` or fall back to `ru`. Blank codes are rejected before querying. API reads and writes remain exact, and storage/migration never normalize codes.
|
||||||
|
|
||||||
|
## Migration and rollout gate
|
||||||
|
|
||||||
|
The current `20261017072410_EmailTemplateLocalizations` EF migration accepts two explicitly recognized source layouts: the flat legacy schema, or the known interim three-entity schema recorded as `20261017070315_EmailTemplateLocalizations`. It checks column names/types/nullability and the expected index/constraint definitions before modifying the schema. An unknown or hybrid layout/history is rejected; the migrator never guesses, drops the database, or rewrites migration history to adopt it. The operation locks the source tables and executes transactionally.
|
||||||
|
|
||||||
|
For the flat legacy schema, it audits all rows before dropping legacy columns:
|
||||||
|
|
||||||
|
1. Reject blank identities, non-array variables, malformed variable objects/types, unsupported JSON fields and duplicate variable names.
|
||||||
|
2. Sort each variable array by exact name and compare the JSON contracts for every service/key group. JSON object-property ordering is already semantic under PostgreSQL `jsonb`.
|
||||||
|
3. On a real conflict, raise a diagnostic containing service/key/language and record IDs only. Bodies, subjects and variable contents are not included in diagnostics. Resolve the source conflict explicitly; rerun only after agreement on the corrected contract.
|
||||||
|
4. Create services and their distinct **unchanged** language codes, then one definition per service/key and attach every existing version. Existing version IDs, content and timestamps remain intact.
|
||||||
|
5. Add required relationships/uniqueness constraints and drop repeated service/key/code/variables columns only after data is linked.
|
||||||
|
|
||||||
|
For the known interim schema, shared variables already belong to definitions. The migration audits their array/object/duplicate-name validity plus blank identities and orphaned references, then upgrades the existing rows in place. Existing service, definition and version IDs, content, shared variables, definition revisions and timestamps remain unchanged. It adds the previously absent service revision, derives the service-language catalog from exact existing codes, links versions to that catalog, and installs the final same-service constraints/indexes. Empty definitions and services without versions survive. Newly introduced catalog IDs and service revisions are generated once. The interim history row is retained and EF records the current migration as an additional row.
|
||||||
|
|
||||||
|
Failure or interruption rolls back schema and data; the current migration history entry is not recorded. A successful rerun is a no-op through EF migration history. Tests cover both source layouts, semantically equal contracts, true conflicts, malformed JSON shapes, interrupted conversion, recovery, exact-code preservation, unrecognized/hybrid layouts, and altered index/foreign-key definitions.
|
||||||
|
|
||||||
|
A downgrade cannot represent empty definitions, unused service languages or catalog-only services. `Down` fails before modifying data when these exist. It also always rejects an interim-origin database: downgrading the current migration cannot reconstruct the interim migration's historical model while targeting the flat legacy model. Restore a verified pre-migration backup for rollback in these cases. Flat-origin data that remains representable has a tested Down/Up path.
|
||||||
|
|
||||||
|
Before a separately authorized rollout: back up the target database, rehearse migration against a disposable restored copy, inspect/resolve reported conflicts, stop old Web/Worker writers during migration, apply the current migrator, then start matching Web and Worker builds together. Verify exact-code clients, administration and a safe email send. Never run old binaries against the new schema. No production data audit or rollout was performed for this implementation.
|
||||||
|
|
||||||
|
## Owner's Windows development setup
|
||||||
|
|
||||||
|
The owner's existing PostgreSQL, Mailpit and Seq containers are infrastructure for later testing; do not recreate or reset them as part of this task. Web runs on the host through Rider with the Development profile and its checked-in connection to `localhost:5433`. Its checked-in HTTP launch URL is `http://localhost:5188` (HTTPS profile also uses `https://localhost:7210`). Keep Docker Web stopped so there is only one Web process. RabbitMQ and Worker are needed only when exercising message delivery.
|
||||||
|
|
||||||
|
The checked-in migrator connection is intentionally empty; when the owner chooses to prepare the local schema, run the current Migrator against the intended local database by explicitly supplying `App__ConnectionString` through a Rider environment setting. The host Web does not apply migrations automatically. Take a backup first and ensure old writers are stopped. The implementation did not migrate the owner's local database or start their Rider application.
|
||||||
|
|
||||||
|
For development seeding, the migrator explicitly creates the configured service and its `en` catalog entry when missing, then idempotently creates the neutral `TestEmail` version and Mailpit channel. Existing versions/channels are not overwritten. This seeding exception is explicit development setup; legacy API calls never create catalog languages.
|
||||||
|
|
||||||
|
## Validation evidence
|
||||||
|
|
||||||
|
Automated checks use disposable PostgreSQL Testcontainers databases, not the owner's database. They cover migration rollback/recovery, API compatibility, empty definitions, shared contracts, stale edits, direct uniqueness/same-service constraints, service form save and counted deletion. Existing worker unit tests cover successful send, SMTP/error behavior and required variables; integration tests exercise exact-language loading/rendering and the bounded worker compatibility retry with the real normalized PostgreSQL repository.
|
||||||
|
|
||||||
|
On 2026-10-17 the interim upgrade was also rehearsed against a fresh disposable restore of the verified pre-migration backup (SHA256 `691CBBD35A49A73F17C7E9FB2A3C1E0BE539F0A9B4DC462E2B37B12AE220BDE3`). The current Migrator in Production mode upgraded 1 service, 1 definition and 1 version, derived 1 exact catalog code, and increased history from 3 to 4 rows while retaining the interim row. SQL assertions compared all existing template/service/definition fields, channel and usage data, and original history with the restored source; they matched. A second migrator run preserved the complete upgraded state. This rehearsal did not apply the repair to the owner's database. Builds/tests used a separate temporary artifacts directory because the owner's running Rider Web process held its normal output DLLs.
|
||||||
|
|
||||||
|
Chromium administration checks on 2026-10-17 included a 1440px desktop viewport and a narrow viewport. They used disposable PostgreSQL at `127.0.0.1:15433` and the task Web build hosted temporarily at `127.0.0.1:52652`: grouped/filter-preserving list, adding `uk`, keyboard tab navigation, unsaved tab content, selected-language preview, HTML variable encoding and script isolation, one shared variable field, malformed JSON preserving active language/input, final-version removal, ordinary removal alert/count, and confirmed deletion of a used language retaining its definition. The temporary Web was stopped afterward. No RabbitMQ-to-SMTP runtime message was sent; existing transport tests remain the evidence for that unchanged path. No Invemory website/Android application changed in this task.
|
||||||
|
|
||||||
|
|
||||||
|
The service-scoped navigation and all-language tab revision was validated separately on 2026-10-17 with temporary host Web at `127.0.0.1:52655` and a fresh, task-owned PostgreSQL database (no restored owner content). Chromium desktop and a 390px touch viewport covered Services name navigation, separate Edit Service, selected-service template list, blank-definition creation, both configured tabs, partial-content errors with no persisted versions, retained input/active tab, keyboard switching, unchanged selected-language preview and HTML isolation/escaping, successful save, and clearing each version through to No versions while keeping the template. The narrow page had no horizontal overflow. The temporary Web and browser tab were stopped afterward. This is browser validation of the Notification Service administration, not validation of an installed Invemory Android application.
|
||||||
@@ -15,7 +15,7 @@ Accepted messages must have all of the following:
|
|||||||
- non-empty `ServiceName`, `TemplateKey`, `RecipientEmail`, `RecipientName`, and `LanguageCode`
|
- non-empty `ServiceName`, `TemplateKey`, `RecipientEmail`, `RecipientName`, and `LanguageCode`
|
||||||
- a valid recipient address and a non-null `Variables` object
|
- a valid recipient address and a non-null `Variables` object
|
||||||
|
|
||||||
The service resolves a template by the exact `ServiceName`, `TemplateKey`, and normalized lower-case `LanguageCode`. It does not silently fall back to another language. Every variable marked `Required` by the selected template must have a non-empty value before rendering.
|
The worker first resolves a template by exact `ServiceName`, `TemplateKey` and the requested `LanguageCode`. If that exact version is missing, it makes one compatibility lookup using `LanguageCode.Trim().ToLowerInvariant()`, only when the result differs ordinally. Thus `RU` selects an existing exact `RU` version before `ru`, while `RU` or ` ru ` can resolve `ru` when their exact version is absent. There is no case-insensitive scan, regional substitution or fallback to another language; `RU-ru` does not search for `ru-RU` or `ru`. Blank codes are rejected. Every variable marked `Required` by the selected template must have a non-empty value before rendering.
|
||||||
|
|
||||||
## Delivery and acknowledgement policy
|
## Delivery and acknowledgement policy
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ confirmed failure.
|
|||||||
|
|
||||||
Delivery remains at least once. There is no inbox/deduplication store in this MVP, so a broker redelivery or a process failure after SMTP acceptance but before acknowledgement can produce a duplicate. `MessageId`, `CorrelationId`, and `MessageType` are added to the structured log scope for correlation. Recipient addresses are masked, and template variable names/values and rendered links are not logged.
|
Delivery remains at least once. There is no inbox/deduplication store in this MVP, so a broker redelivery or a process failure after SMTP acceptance but before acknowledgement can produce a duplicate. `MessageId`, `CorrelationId`, and `MessageType` are added to the structured log scope for correlation. Recipient addresses are masked, and template variable names/values and rendered links are not logged.
|
||||||
|
|
||||||
No database migration is required. Existing channel selection, SMTP settings, quota checks, and usage counters remain in place.
|
The template model now requires the transactional [email template localization migration](email-template-localizations.md). Existing channel selection, SMTP settings, quota checks, and usage counters remain in place. Exact language codes, including regional variants, are preserved. API requests remain exact; worker sends retain only the bounded compatibility retry described above. Prefer the configured code to make the selected version explicit.
|
||||||
|
|
||||||
## Local end-to-end setup
|
## Local end-to-end setup
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user