feat(templates): add localized push templates and actions

Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery.

Ref: IT-1627
This commit is contained in:
2026-10-31 09:38:05 +02:00
parent d4ee8fc0c8
commit aba4173827
79 changed files with 3906 additions and 104 deletions
@@ -0,0 +1,6 @@
namespace HrynCo.NotificationService.Contracts.Messages;
public sealed record PushActionMetadata
{
public string? ObjectId { get; init; }
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.Contracts.Messages;
public sealed record PushTemplateData
{
public required IReadOnlyDictionary<string, string> Bindings { get; init; }
public required IReadOnlyDictionary<string, PushActionMetadata> Actions { get; init; }
}
@@ -6,9 +6,12 @@ public sealed record SendPushMessageData
public required Guid BindingId { get; init; }
public required Guid UserId { get; init; }
public required string ServiceName { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public string? Title { get; init; }
public string? Body { get; init; }
public string Route { get; init; } = "/home";
public string? TemplateKey { get; init; }
public string? LanguageCode { get; init; }
public PushTemplateData? TemplateData { get; init; }
public int Version { get; init; } = 1;
public required DateTimeOffset ExpiresAt { get; init; }
public string? Token { get; init; }
@@ -0,0 +1,11 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IPushTemplateDefinitionRepository
{
Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default);
Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default);
Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default);
Task<bool> DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default);
}
@@ -0,0 +1,49 @@
using System.Text.Json;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public static class PushTemplateActionValidation
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = false
};
public static PushTemplateActions Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new EmailTemplateWriteException("Actions JSON is required. Use {} for no actions.", false);
try
{
var actions = JsonSerializer.Deserialize<PushTemplateActions>(json, JsonOptions)
?? throw new JsonException();
Validate(actions);
return actions;
}
catch (JsonException)
{
throw new EmailTemplateWriteException("Invalid actions JSON. Use defaultAction and buttons with action and label.", false);
}
}
public static void Validate(PushTemplateActions actions)
{
if (actions.DefaultAction is not null) ValidateKey(actions.DefaultAction);
if (actions.Buttons is null || actions.Buttons.Count > 3)
throw new EmailTemplateWriteException("Configure up to three push buttons.", false);
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var button in actions.Buttons)
{
if (button is null) throw new EmailTemplateWriteException("A button needs an action and label.", false);
ValidateKey(button.Action);
if (!keys.Add(button.Action) || string.IsNullOrWhiteSpace(button.Label) || button.Label.Length > 100)
throw new EmailTemplateWriteException("Button actions must be unique and labels must contain 1–100 characters.", false);
}
}
private static void ValidateKey(string key)
{
if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || key.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '_' and not '-' and not '.'))
throw new EmailTemplateWriteException("Action keys need 1–100 letters, digits, dots, underscores or hyphens.", false);
}
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record PushTemplateActions
{
public string? DefaultAction { get; init; }
public IReadOnlyList<PushTemplateButton> Buttons { get; init; } = [];
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record PushTemplateButton
{
public required string Action { get; init; }
public required string Label { get; init; }
}
@@ -0,0 +1,11 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class PushTemplateDefinition
{
public Guid Id { get; set; }
public Guid ServiceId { get; set; }
public required string Key { get; set; }
public Guid Revision { get; set; }
public IReadOnlyList<EmailTemplateVariable> Variables { get; set; } = [];
public IReadOnlyList<PushTemplateTranslation> Languages { get; set; } = [];
}
@@ -0,0 +1,9 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class PushTemplateTranslation
{
public string ActionsJson { get; set; } = "{}";
public required string LanguageCode { get; set; }
public required string Title { get; set; }
public required string Body { get; set; }
}
@@ -0,0 +1,42 @@
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public static partial class PushTemplateValidation
{
public static void Validate(PushTemplateDefinition definition)
{
EmailTemplateValidation.ValidateIdentity("Push", definition.Key);
EmailTemplateValidation.ValidateVariables(definition.Variables);
var names = definition.Variables.Select(v => v.Name).ToHashSet(StringComparer.Ordinal);
foreach (var language in definition.Languages)
ValidateTranslation(language, names);
if (definition.Languages.Select(l => l.LanguageCode).Distinct(StringComparer.Ordinal).Count() != definition.Languages.Count)
throw new EmailTemplateWriteException("Language codes must be unique within a template.", false);
}
private static void ValidateTranslation(PushTemplateTranslation translation, HashSet<string> names)
{
if (string.IsNullOrWhiteSpace(translation.LanguageCode) || translation.LanguageCode.Length > 10)
throw new EmailTemplateWriteException("A valid language code is required.", false);
if (string.IsNullOrWhiteSpace(translation.Title) || string.IsNullOrWhiteSpace(translation.Body))
throw new EmailTemplateWriteException("Every populated push language needs both Title and Body.", false);
ValidatePlaceholders(translation.Title, names);
ValidatePlaceholders(translation.Body, names);
var actions = PushTemplateActionValidation.Parse(translation.ActionsJson);
foreach (var button in actions.Buttons) ValidatePlaceholders(button.Label, names);
}
private static void ValidatePlaceholders(string text, HashSet<string> names)
{
foreach (Match match in Placeholder().Matches(text))
if (!names.Contains(match.Groups[1].Value))
throw new EmailTemplateWriteException("Every placeholder must be declared in this channel's variables.", false);
var remainder = Placeholder().Replace(text, "");
if (remainder.Contains("{{", StringComparison.Ordinal) || remainder.Contains("}}", StringComparison.Ordinal))
throw new EmailTemplateWriteException("Use complete placeholders in the form {{VariableName}}.", false);
}
[GeneratedRegex(@"\{\{([^{}]+)\}\}")]
private static partial Regex Placeholder();
}
@@ -17,6 +17,9 @@ internal sealed class EmailTemplateDefinitionEntityConfiguration : IEntityTypeCo
builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique();
builder.HasAlternateKey(x => new { x.Id, x.ServiceId });
builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Template).WithOne(x => x.Email)
.HasForeignKey<EmailTemplateDefinitionEntity>(x => new { x.ServiceId, x.Key })
.HasPrincipalKey<TemplateEntity>(x => new { x.ServiceId, x.Key }).OnDelete(DeleteBehavior.Cascade);
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
builder.OwnsMany(x => x.Variables, v =>
@@ -0,0 +1,31 @@
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal sealed class PushTemplateDefinitionEntityConfiguration : IEntityTypeConfiguration<PushTemplateDefinitionEntity>
{
public void Configure(EntityTypeBuilder<PushTemplateDefinitionEntity> builder)
{
builder.ToTable("push_template_definitions");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceId).HasColumnName("service_id");
builder.Property(x => x.Key).HasColumnName("key").HasMaxLength(100).IsRequired();
builder.Property(x => x.Revision).HasColumnName("revision").IsConcurrencyToken();
builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique();
builder.HasAlternateKey(x => new { x.Id, x.ServiceId });
builder.HasOne(x => x.Template).WithOne(x => x.Push)
.HasForeignKey<PushTemplateDefinitionEntity>(x => new { x.ServiceId, x.Key })
.HasPrincipalKey<TemplateEntity>(x => new { x.ServiceId, x.Key }).OnDelete(DeleteBehavior.Cascade);
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
builder.OwnsMany(x => x.Variables, v =>
{
v.ToJson("variables");
v.Property(x => x.Name).HasJsonPropertyName("name");
v.Property(x => x.Required).HasJsonPropertyName("required");
});
}
}
@@ -0,0 +1,30 @@
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal sealed class PushTemplateEntityConfiguration : IEntityTypeConfiguration<PushTemplateEntity>
{
public void Configure(EntityTypeBuilder<PushTemplateEntity> builder)
{
builder.ToTable("push_templates");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.DefinitionId).HasColumnName("definition_id");
builder.Property(x => x.ServiceId).HasColumnName("service_id");
builder.Property(x => x.ServiceLanguageId).HasColumnName("service_language_id");
builder.HasIndex(x => new { x.DefinitionId, x.ServiceLanguageId }).IsUnique();
builder.HasOne(x => x.Definition).WithMany(x => x.Languages)
.HasForeignKey(x => new { x.DefinitionId, x.ServiceId })
.HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.ServiceLanguage).WithMany()
.HasForeignKey(x => new { x.ServiceLanguageId, x.ServiceId })
.HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Title).HasColumnName("title").IsRequired();
builder.Property(x => x.Body).HasColumnName("body").IsRequired();
builder.Property(x => x.ActionsJson).HasColumnName("actions").HasColumnType("jsonb").IsRequired();
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
}
}
@@ -0,0 +1,21 @@
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal sealed class TemplateEntityConfiguration : IEntityTypeConfiguration<TemplateEntity>
{
public void Configure(EntityTypeBuilder<TemplateEntity> builder)
{
builder.ToTable("templates");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceId).HasColumnName("service_id");
builder.Property(x => x.Key).HasColumnName("key").HasMaxLength(100).IsRequired();
builder.HasAlternateKey(x => new { x.ServiceId, x.Key });
builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
}
}
@@ -4,6 +4,7 @@ namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class EmailTemplateDefinitionEntity : Entity
{
public TemplateEntity Template { get; set; } = null!;
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
public required string Key { get; set; }
@@ -0,0 +1,13 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushTemplateDefinitionEntity : Entity
{
public Guid ServiceId { get; set; }
public required string Key { get; set; }
public TemplateEntity Template { get; set; } = null!;
public Guid Revision { get; set; } = Guid.NewGuid();
public List<PushTemplateVariableData> Variables { get; set; } = [];
public List<PushTemplateEntity> Languages { get; set; } = [];
}
@@ -0,0 +1,15 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushTemplateEntity : Entity
{
public Guid DefinitionId { get; set; }
public PushTemplateDefinitionEntity Definition { get; set; } = null!;
public Guid ServiceId { get; set; }
public Guid ServiceLanguageId { get; set; }
public ServiceLanguageEntity ServiceLanguage { get; set; } = null!;
public required string Title { get; set; }
public required string Body { get; set; }
public string ActionsJson { get; set; } = "{}";
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushTemplateVariableData
{
public required string Name { get; set; }
public bool Required { get; set; }
}
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class TemplateEntity : Entity
{
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
public required string Key { get; set; }
public EmailTemplateDefinitionEntity? Email { get; set; }
public PushTemplateDefinitionEntity? Push { get; set; }
}
@@ -0,0 +1,674 @@
// <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("20261030215421_AddChannelTemplates")]
partial class AddChannelTemplates
{
/// <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<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
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("ServiceId", "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.PushChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("CredentialsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("credentials");
b.Property<string>("FirebaseProjectId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("firebase_project_id");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
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")
.IsUnique();
b.ToTable("push_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Property<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("push_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
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("push_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.TemplateEntity", 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>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.ToTable("templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Email")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<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("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Push")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("PushTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("push_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("PushTemplateDefinitionEntityId");
});
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Navigation("Email");
b.Navigation("Push");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,151 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddChannelTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "templates",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_templates", x => x.id);
table.UniqueConstraint("AK_templates_service_id_key", x => new { x.service_id, x.key });
table.ForeignKey(
name: "FK_templates_services_service_id",
column: x => x.service_id,
principalTable: "services",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "push_template_definitions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
revision = table.Column<Guid>(type: "uuid", nullable: false),
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
variables = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_push_template_definitions", x => x.id);
table.UniqueConstraint("AK_push_template_definitions_id_service_id", x => new { x.id, x.service_id });
table.ForeignKey(
name: "FK_push_template_definitions_templates_service_id_key",
columns: x => new { x.service_id, x.key },
principalTable: "templates",
principalColumns: new[] { "service_id", "key" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "push_templates",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
definition_id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
service_language_id = table.Column<Guid>(type: "uuid", nullable: false),
title = table.Column<string>(type: "text", nullable: false),
body = table.Column<string>(type: "text", nullable: false),
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_push_templates", x => x.id);
table.ForeignKey(
name: "FK_push_templates_push_template_definitions_definition_id_serv~",
columns: x => new { x.definition_id, x.service_id },
principalTable: "push_template_definitions",
principalColumns: new[] { "id", "service_id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_push_templates_service_languages_service_language_id_servic~",
columns: x => new { x.service_language_id, x.service_id },
principalTable: "service_languages",
principalColumns: new[] { "id", "service_id" },
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_push_template_definitions_service_id_key",
table: "push_template_definitions",
columns: new[] { "service_id", "key" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_push_templates_definition_id_service_id",
table: "push_templates",
columns: new[] { "definition_id", "service_id" });
migrationBuilder.CreateIndex(
name: "IX_push_templates_definition_id_service_language_id",
table: "push_templates",
columns: new[] { "definition_id", "service_language_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_push_templates_service_language_id_service_id",
table: "push_templates",
columns: new[] { "service_language_id", "service_id" });
// Preserve existing email identity, variables, revisions and translations in place.
migrationBuilder.Sql("""
INSERT INTO templates (id, service_id, key, created, updated)
SELECT id, service_id, key, created, updated FROM email_template_definitions;
""");
migrationBuilder.AddForeignKey(
name: "FK_email_template_definitions_templates_service_id_key",
table: "email_template_definitions",
columns: new[] { "service_id", "key" },
principalTable: "templates",
principalColumns: new[] { "service_id", "key" },
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM push_template_definitions) THEN
RAISE EXCEPTION 'Cannot roll back channel templates while push definitions exist. Export and explicitly remove them first.';
END IF;
END $$;
""");
migrationBuilder.DropForeignKey(
name: "FK_email_template_definitions_templates_service_id_key",
table: "email_template_definitions");
migrationBuilder.DropTable(
name: "push_templates");
migrationBuilder.DropTable(
name: "push_template_definitions");
migrationBuilder.DropTable(
name: "templates");
}
}
}
@@ -0,0 +1,679 @@
// <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("20261030235620_AddPushTemplateActions")]
partial class AddPushTemplateActions
{
/// <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<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
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("ServiceId", "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.PushChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("CredentialsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("credentials");
b.Property<string>("FirebaseProjectId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("firebase_project_id");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
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")
.IsUnique();
b.ToTable("push_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Property<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("push_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ActionsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("actions");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
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("push_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.TemplateEntity", 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>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.ToTable("templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Email")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<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("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Push")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("PushTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("push_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("PushTemplateDefinitionEntityId");
});
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Navigation("Email");
b.Navigation("Push");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddPushTemplateActions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "actions",
table: "push_templates",
type: "jsonb",
nullable: false,
defaultValue: "{}");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "actions",
table: "push_templates");
}
}
}
@@ -272,6 +272,98 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Property<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("push_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ActionsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("actions");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
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("push_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Property<Guid>("Id")
@@ -339,6 +431,36 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.ToTable("service_languages", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Property<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>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.ToTable("templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
@@ -367,6 +489,13 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Email")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
@@ -395,6 +524,8 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Service");
b.Navigation("Template");
b.Navigation("Variables");
});
@@ -430,6 +561,67 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Push")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("PushTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("push_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("PushTemplateDefinitionEntityId");
});
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
@@ -441,6 +633,17 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
@@ -451,10 +654,22 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Navigation("Email");
b.Navigation("Push");
});
#pragma warning restore 612, 618
}
}
@@ -10,6 +10,9 @@ public class NotificationDbContext : DbContext
{
}
internal DbSet<TemplateEntity> TemplateCatalog => Set<TemplateEntity>();
internal DbSet<PushTemplateDefinitionEntity> PushTemplateDefinitions => Set<PushTemplateDefinitionEntity>();
internal DbSet<PushTemplateEntity> PushTemplates => Set<PushTemplateEntity>();
internal DbSet<ServiceLanguageEntity> ServiceLanguages => Set<ServiceLanguageEntity>();
internal DbSet<PushChannelEntity> PushChannels => Set<PushChannelEntity>();
internal DbSet<ServiceEntity> Services => Set<ServiceEntity>();
@@ -78,6 +78,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
if (entity is not null)
throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating.");
entity = new EmailTemplateDefinitionEntity { Service = service, ServiceId = service.Id, Key = patch.Key, Created = DateTimeOffset.UtcNow };
entity.Template = await GetOrCreateTemplateAsync(service, patch.Key, ct);
db.TemplateDefinitions.Add(entity);
return entity;
}
@@ -125,6 +126,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
if (definition.Revision != revision)
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
db.TemplateDefinitions.Remove(definition);
await RemoveEmptyTemplateAsync(serviceId, key, ct);
removed = true;
}, ct);
return removed;
@@ -261,12 +263,31 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false);
var entity = new EmailTemplateDefinitionEntity
{
Template = await GetOrCreateTemplateAsync(service, key, ct),
Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow
};
db.TemplateDefinitions.Add(entity);
return entity;
}
private async Task<TemplateEntity> GetOrCreateTemplateAsync(ServiceEntity service, string key, CancellationToken ct)
{
var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == key, ct);
if (template is not null)
return template;
template = new TemplateEntity { Service = service, ServiceId = service.Id, Key = key, Created = DateTimeOffset.UtcNow };
db.TemplateCatalog.Add(template);
return template;
}
private async Task RemoveEmptyTemplateAsync(Guid serviceId, string key, CancellationToken ct)
{
if (await db.PushTemplateDefinitions.AnyAsync(t => t.ServiceId == serviceId && t.Key == key, ct))
return;
var template = await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct);
db.TemplateCatalog.Remove(template);
}
private static void ValidateLegacy(EmailTemplate template)
{
EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key);
@@ -48,7 +48,7 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
private async Task EnsureServiceIsEmptyAsync(Guid id, CancellationToken ct)
{
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct))
if (await db.TemplateCatalog.AnyAsync(d => d.ServiceId == id, ct))
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
if (await db.Providers.AnyAsync(c => c.ServiceId == id, ct) || await db.PushChannels.AnyAsync(c => c.ServiceId == id, ct))
throw new EmailTemplateWriteException("Reassign or remove every notification channel before deleting this service.");
@@ -76,9 +76,11 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
var language = service.Languages.SingleOrDefault(l => l.Id == languageId)
?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service.");
var versions = await db.Templates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
if (service.Revision != expectedRevision || versions.Count != expectedVersionCount)
var pushVersions = await db.PushTemplates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
if (service.Revision != expectedRevision || versions.Count + pushVersions.Count != expectedVersionCount)
throw new EmailTemplateWriteException("The service or affected version count changed. Review the current count and confirm again.");
db.Templates.RemoveRange(versions);
db.PushTemplates.RemoveRange(pushVersions);
// Save dependent removals first because language deletion is restricted by its FK.
await db.SaveChangesAsync(ct);
db.ServiceLanguages.Remove(language);
@@ -112,7 +114,8 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
var removed = service.Languages.Where(l => !retained.Contains(l.Id)).ToList();
foreach (var language in removed)
{
var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct);
var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct)
+ await db.PushTemplates.CountAsync(t => t.ServiceLanguageId == language.Id, ct);
if (count > 0)
throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation.");
}
@@ -168,10 +171,17 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
definition.Revision = Guid.NewGuid();
definition.Updated = DateTimeOffset.UtcNow;
}
var pushDefinitions = await db.PushTemplateDefinitions.Where(d => d.ServiceId == serviceId).ToListAsync(ct);
foreach (var definition in pushDefinitions)
{
definition.Revision = Guid.NewGuid();
definition.Updated = DateTimeOffset.UtcNow;
}
}
private Task<Dictionary<Guid, int>> VersionCountsAsync(CancellationToken ct) => db.Templates.AsNoTracking()
.GroupBy(t => t.ServiceLanguageId).Select(g => new { Id = g.Key, Count = g.Count() })
.Select(t => t.ServiceLanguageId).Concat(db.PushTemplates.Select(t => t.ServiceLanguageId))
.GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.Id, g => g.Count, ct);
private static void Validate(NotificationServiceInfo service)
@@ -0,0 +1,108 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories;
internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, TemplateMutationRunner mutations)
: IPushTemplateDefinitionRepository
{
private IQueryable<PushTemplateDefinitionEntity> Definitions => db.PushTemplateDefinitions
.Include(d => d.Languages).ThenInclude(l => l.ServiceLanguage);
public async Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) =>
(await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct)).Select(Map).ToList();
public async Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default)
{
var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
return entity is null ? null : Map(entity);
}
public Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{
PushTemplateValidation.Validate(definition);
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == definition.ServiceId, ct)
?? throw new EmailTemplateWriteException("Service no longer exists. Reload services.");
if (definition.Languages.Any(l => !service.Languages.Any(s => s.Code == l.LanguageCode)))
throw new EmailTemplateWriteException("Every language must exactly match this service's catalog.", false);
var entity = await LoadForSaveAsync(definition, service, isNew, ct);
entity.Variables = definition.Variables.Select(v => new PushTemplateVariableData { Name = v.Name, Required = v.Required }).ToList();
SynchronizeLanguages(entity, service, definition.Languages);
entity.Revision = Guid.NewGuid();
entity.Updated = DateTimeOffset.UtcNow;
definition.Id = entity.Id;
definition.Revision = entity.Revision;
}, ct);
private async Task<PushTemplateDefinitionEntity> LoadForSaveAsync(PushTemplateDefinition model, ServiceEntity service, bool isNew, CancellationToken ct)
{
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == service.Id && d.Key == model.Key, ct);
if (!isNew)
{
if (entity is null || entity.Id != model.Id || entity.Revision != model.Revision)
throw new EmailTemplateWriteException("This push template changed after you opened it. Reload before saving; your entered values are retained.");
return entity;
}
if (entity is not null)
throw new EmailTemplateWriteException("A push template with this key already exists.");
var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == model.Key, ct)
?? new TemplateEntity { Service = service, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow };
entity = new PushTemplateDefinitionEntity { Template = template, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow };
db.PushTemplateDefinitions.Add(entity);
return entity;
}
private void SynchronizeLanguages(PushTemplateDefinitionEntity definition, ServiceEntity service, IReadOnlyList<PushTemplateTranslation> translations)
{
var codes = translations.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
db.PushTemplates.RemoveRange(definition.Languages.Where(l => !codes.Contains(l.ServiceLanguage.Code)));
foreach (var translation in translations)
{
var entity = definition.Languages.SingleOrDefault(l => l.ServiceLanguage.Code == translation.LanguageCode);
if (entity is null)
{
entity = new PushTemplateEntity
{
Definition = definition, ServiceId = service.Id,
ServiceLanguage = service.Languages.Single(l => l.Code == translation.LanguageCode),
Title = translation.Title, Body = translation.Body, Created = DateTimeOffset.UtcNow
};
definition.Languages.Add(entity);
}
entity.Title = translation.Title;
entity.Body = translation.Body;
entity.ActionsJson = translation.ActionsJson;
entity.Updated = DateTimeOffset.UtcNow;
}
}
public async Task<bool> DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default)
{
var removed = false;
await mutations.ExecuteAsync(async () =>
{
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
if (entity is null)
return;
if (revision == Guid.Empty || entity.Revision != revision)
throw new EmailTemplateWriteException("The push template revision changed. Reload before deleting.");
db.PushTemplateDefinitions.Remove(entity);
if (!await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == serviceId && d.Key == key, ct))
db.TemplateCatalog.Remove(await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct));
removed = true;
}, ct);
return removed;
}
private static PushTemplateDefinition Map(PushTemplateDefinitionEntity entity) => new()
{
Id = entity.Id, ServiceId = entity.ServiceId, Key = entity.Key, Revision = entity.Revision,
Variables = entity.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(),
Languages = entity.Languages.OrderBy(l => l.ServiceLanguage.Code).Select(l => new PushTemplateTranslation
{
LanguageCode = l.ServiceLanguage.Code, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson
}).ToList()
};
}
@@ -20,6 +20,7 @@ public static class ServiceCollectionExtensions
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.PushChannels.IPushChannelRepository, PushChannelRepository>();
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.Push.IPushDeliveryRepository, PushDeliveryRepository>();
services.AddScoped<INotificationServiceRepository, NotificationServiceRepository>();
services.AddScoped<IPushTemplateDefinitionRepository, PushTemplateDefinitionRepository>();
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>();
services.AddScoped<IEmailChannelRepository, EmailChannelRepository>();
@@ -12,12 +12,16 @@ namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class FcmPushSenderTests
{
[Fact]
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials()
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials(int version)
{
var services = Substitute.For<INotificationServiceRepository>();
var channels = Substitute.For<IPushChannelRepository>();
var client = Substitute.For<IFirebasePushClient>();
var templates = Substitute.For<IPushTemplateDefinitionRepository>();
templates.GetAsync(Arg.Any<Guid>(), "Test", Arg.Any<CancellationToken>()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] });
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory-Renamed" };
var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true };
services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
@@ -29,12 +33,16 @@ public sealed class FcmPushSenderTests
private_key = rsa.ExportPkcs8PrivateKeyPem(), token_uri = "https://untrusted.invalid/token"
});
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns(json);
client.SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
client.SendAsync(Arg.Any<Guid>(), Arg.Any<RenderedPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
var data = version == 1 ? Data(service.Name) with { Version = 1, Title = "Legacy title", Body = "Legacy body", TemplateKey = null, TemplateData = null } : Data(service.Name);
var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), data, CancellationToken.None);
Assert.Null(result.Error);
await client.Received(1).SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(),
await client.Received(1).SendAsync(Arg.Any<Guid>(), Arg.Any<RenderedPushMessageData>(),
Arg.Is<FirebaseServiceAccountCredentials>(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any<CancellationToken>());
await templates.Received(version == 1 ? 0 : 1).GetAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
if (version == 1)
await client.Received().SendAsync(Arg.Any<Guid>(), Arg.Is<RenderedPushMessageData>(m => m.Title == "Legacy title" && m.Body == "Legacy body" && m.Buttons.Count == 0), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
}
[Theory]
@@ -46,6 +54,8 @@ public sealed class FcmPushSenderTests
var services = Substitute.For<INotificationServiceRepository>();
var channels = Substitute.For<IPushChannelRepository>();
var client = Substitute.For<IFirebasePushClient>();
var templates = Substitute.For<IPushTemplateDefinitionRepository>();
templates.GetAsync(Arg.Any<Guid>(), "Test", Arg.Any<CancellationToken>()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] });
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" };
if (exists) services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
if (active)
@@ -54,7 +64,7 @@ public sealed class FcmPushSenderTests
channels.GetActiveForServiceAsync(service.Id, Arg.Any<CancellationToken>()).Returns(channel);
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns("secret-must-not-leak");
}
var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
Assert.NotNull(result.Error);
Assert.DoesNotContain("secret-must-not-leak", result.Error);
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
@@ -72,7 +82,7 @@ public sealed class FcmPushSenderTests
private static SendPushMessageData Data(string serviceName) => new()
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ServiceName = serviceName, Title = "Test", Body = "Test", Token = "test-device-token",
ServiceName = serviceName, Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, Token = "test-device-token",
TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10)
};
}
@@ -23,7 +23,7 @@ public sealed class PushChannelTestServiceTests
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ ChannelId = id, FirebaseProjectId = "test-project", RecipientToken = send ? "test-token" : null }, send, default);
Assert.Null(outcome.Error);
await firebase.Received(send ? 1 : 0).SendTestAsync("test-token", Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await firebase.Received(send ? 1 : 0).SendTestAsync(Arg.Is<PushChannelTestMessage>(m => m.Token == "test-token" && m.Version == 1), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default);
}
@@ -70,7 +70,7 @@ public sealed class PushChannelTestServiceTests
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
firebase.SendTestAsync(Arg.Any<PushChannelTestMessage>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PushDeliveryOutcome>(new PushRetryException(TimeSpan.FromSeconds(30))));
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default);
@@ -82,7 +82,7 @@ public sealed class PushChannelTestServiceTests
var firebase = Substitute.For<IFirebasePushClient>();
firebase.CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
firebase.SendTestAsync(Arg.Any<PushChannelTestMessage>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
return firebase;
}
@@ -49,7 +49,7 @@ public sealed class PushDeliveryTests
Data = new SendPushMessageData
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Title = "Invemory", Body = "Test", TokenHash = new string('a', 64)
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, TokenHash = new string('a', 64)
}
};
await service.CompleteFailureAsync(id, message, default);
@@ -0,0 +1,105 @@
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
using NSubstitute;
namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class PushPayloadVersionTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void OriginalPayloadDeserializesWithoutTemplateFieldsAndKeepsExactLegacyOutput(bool explicitVersion)
{
var json = JsonSerializer.SerializeToNode(new
{
registrationId = Guid.NewGuid(), bindingId = Guid.NewGuid(), userId = Guid.NewGuid(),
serviceName = "Invemory", title = "Original title", body = "Original body",
token = "device-token", tokenHash = new string('a', 64), expiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
})!;
if (explicitVersion) json["version"] = 1;
var data = json.Deserialize<SendPushMessageData>(new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
var message = new SendPushMessage { Data = data, CorrelationContext = new CorrelationContext { CorrelationId = Guid.NewGuid().ToString() } };
Assert.True(PushMessageValidator.IsValid(message));
Assert.Null(data.TemplateData);
var id = Guid.NewGuid();
var payload = FirebasePushClient.BuildPayload(id, new RenderedPushMessageData(data, data.Title!, data.Body!, null, []));
Assert.Equal(7, payload.Count);
Assert.Equal("1", payload["version"]);
Assert.Equal("/home", payload["route"]);
Assert.Equal(id.ToString("D"), payload["messageId"]);
Assert.Equal(data.BindingId.ToString("D"), payload["bindingId"]);
Assert.Equal("Original title", payload["title"]);
Assert.Equal("Original body", payload["body"]);
Assert.False(payload.ContainsKey("actions"));
}
[Theory]
[InlineData(0)]
[InlineData(3)]
[InlineData(-1)]
public async Task UnsupportedVersionNeverFallsBackOrContactsFirebase(int version)
{
var data = Data() with { Version = version, Title = "Legacy", Body = "Legacy" };
Assert.False(PushMessageValidator.IsValidContent(data));
var client = Substitute.For<IFirebasePushClient>();
var sender = new FcmPushSender(Substitute.For<INotificationServiceRepository>(), Substitute.For<IPushChannelRepository>(), client, Substitute.For<IPushTemplateDefinitionRepository>());
var result = await sender.SendAsync(Guid.NewGuid(), data, default);
Assert.Contains("Unsupported push payload version", result.Error);
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
Assert.Throws<ArgumentOutOfRangeException>(() => FirebasePushClient.BuildPayload(Guid.NewGuid(), new RenderedPushMessageData(data, "Title", "Body", null, [])));
}
[Fact]
public void SectionActionHasNoObjectIdAndVersionTwoNeverContainsLegacyRoute()
{
var template = new PushTemplateDefinition { Key = "Section", Languages = [new PushTemplateTranslation
{
LanguageCode = "uk", Title = "Зміни", Body = "Перегляньте зміни",
ActionsJson = "{\"defaultAction\":\"openBasket\",\"buttons\":[{\"action\":\"openBasket\",\"label\":\"Перейти в розділ\"}]}"
}] };
var data = Data();
var rendered = PushTemplateRenderer.Render(template, data);
Assert.Null(Assert.Single(rendered.Buttons).ObjectId);
var payload = FirebasePushClient.BuildPayload(Guid.NewGuid(), rendered);
Assert.Equal("2", payload["version"]);
Assert.False(payload.ContainsKey("route"));
using var actions = JsonDocument.Parse(payload["actions"]);
var button = actions.RootElement.GetProperty("buttons")[0];
Assert.Equal("openBasket", button.GetProperty("action").GetString());
Assert.Equal("Перейти в розділ", button.GetProperty("label").GetString());
Assert.False(button.TryGetProperty("objectId", out _));
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(template, data with { TemplateData = data.TemplateData! with { Actions = new Dictionary<string, PushActionMetadata>() } }));
}
[Fact]
public void TestSendBuildsActualButtonPayloadWithRealBindingAndPreservesSimpleLegacyTest()
{
var binding = Guid.NewGuid();
var test = new PushChannelTestMessage { Token = "test-token", Version = 2, BindingId = binding, ActionKey = "openBasket", ButtonLabel = "Перейти в розділ" };
var current = FirebasePushClient.BuildTestMessage(test);
Assert.Null(current.Notification);
Assert.Equal("2", current.Data["version"]);
Assert.Equal(binding.ToString("D"), current.Data["bindingId"]);
using var actions = JsonDocument.Parse(current.Data["actions"]);
Assert.Equal("openBasket", actions.RootElement.GetProperty("buttons")[0].GetProperty("action").GetString());
Assert.False(actions.RootElement.GetProperty("buttons")[0].TryGetProperty("objectId", out _));
var legacy = FirebasePushClient.BuildTestMessage(test with { Version = 1, BindingId = null });
Assert.NotNull(legacy.Notification);
Assert.True(legacy.Data is null || legacy.Data.Count == 0);
Assert.NotNull((test with { BindingId = null }).Validate());
Assert.NotNull((test with { Version = 9 }).Validate());
}
private static SendPushMessageData Data() => new()
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), ServiceName = "Invemory",
Version = 2, TemplateKey = "Section", LanguageCode = "uk", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata> { ["openBasket"] = new() } }
};
}
@@ -0,0 +1,90 @@
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.Push;
namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class PushTemplateRendererTests
{
[Fact]
public void RendersLocalizedLabelsAndOpaqueObjectIdsWithoutReinterpretingValues()
{
var result = PushTemplateRenderer.Render(Template(), Message());
Assert.Equal("Hello {{Injected}}", result.Title);
var button = Assert.Single(result.Buttons);
Assert.Equal("View {{Injected}}", button.Label);
Assert.Equal("need-42", button.ObjectId);
Assert.Equal("open", result.DefaultAction!.Action);
using var payload = JsonDocument.Parse(FirebasePushClient.BuildPayload(Guid.NewGuid(), result)["actions"]);
Assert.Equal("need-42", payload.RootElement.GetProperty("buttons")[0].GetProperty("objectId").GetString());
Assert.Equal("open", payload.RootElement.GetProperty("defaultAction").GetProperty("action").GetString());
}
[Fact]
public void UsesEnglishFallbackAndRejectsMissingFallback()
{
var message = Message() with { LanguageCode = "uk" };
Assert.Equal("Hello {{Injected}}", PushTemplateRenderer.Render(Template(), message).Title);
var template = Template();
template.Languages = [];
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(template, message));
}
[Fact]
public void MissingBindingOrConfiguredActionFailsInsteadOfSendingIncompleteNotification()
{
var message = Message();
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(Template(), message with
{
TemplateData = message.TemplateData! with { Bindings = new Dictionary<string, string>() }
}));
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(Template(), message with
{
TemplateData = message.TemplateData! with { Actions = new Dictionary<string, PushActionMetadata>() }
}));
}
[Fact]
public void TruncationKeepsUnicodeTextElementsIntact()
{
var message = Message() with
{
TemplateData = Message().TemplateData! with { Bindings = new Dictionary<string, string> { ["Name"] = string.Concat(Enumerable.Repeat("😀", 100)) } }
};
var result = PushTemplateRenderer.Render(Template(), message);
Assert.True(result.Title.Length <= 100);
Assert.EndsWith("…", result.Title);
Assert.False(char.IsHighSurrogate(result.Title[^2]));
}
[Theory]
[InlineData("{\"buttons\":null}")]
[InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"\"}]}")]
[InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"Open\"},{\"action\":\"open\",\"label\":\"Again\"}]}")]
[InlineData("{\"defaultAction\":\"https://example.invalid\"}")]
[InlineData("{\"unknown\":true}")]
public void RejectsInvalidActionDefinitions(string json) =>
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateActionValidation.Parse(json));
private static PushTemplateDefinition Template() => new()
{
Key = "Shared", Variables = [new EmailTemplateVariable { Name = "Name", Required = true }],
Languages = [new PushTemplateTranslation
{
LanguageCode = "en", Title = "Hello {{Name}}", Body = "Shared item",
ActionsJson = "{\"defaultAction\":\"open\",\"buttons\":[{\"action\":\"open\",\"label\":\"View {{Name}}\"}]}"
}]
};
private static SendPushMessageData Message() => new()
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ServiceName = "Example", Version = 2, TemplateKey = "Shared", LanguageCode = "en", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
TemplateData = new PushTemplateData
{
Bindings = new Dictionary<string, string> { ["Name"] = "{{Injected}}" },
Actions = new Dictionary<string, PushActionMetadata> { ["open"] = new() { ObjectId = "need-42" } }
}
};
}
@@ -27,7 +27,7 @@ public sealed class SendPushDeliveryValidatorTests
Data = new SendPushMessageData
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ServiceName = "Invemory", Title = "Test", Body = "Test", Token = "test-device-token",
ServiceName = "Invemory", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, Token = "test-device-token",
TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10)
}
};
@@ -102,7 +102,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var root = $"/admin/services/{context.ServiceId}";
var templates = await context.Client.GetStringAsync(root);
Assert.Contains("No email templates for this service yet.", templates);
Assert.Contains("No templates for this service yet.", templates);
Assert.Contains("Create First Template", templates);
Assert.Contains(root + "/templates/create", templates);
Assert.Contains("template-key-filter", templates);
@@ -129,7 +129,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
Assert.Contains("Could not load templates", html);
Assert.Contains("Templates are unavailable", html);
Assert.DoesNotContain("Create First Template", html);
Assert.DoesNotContain("No email templates for this service yet.", html);
Assert.DoesNotContain("No templates for this service yet.", html);
}
private static void AssertConfirmationReset(string html) =>
@@ -8,7 +8,7 @@ public sealed class AdminTemplatesIndexViewTests
string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml"));
Assert.Contains("asp-route-serviceId=\"@serviceId\"", view);
Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view);
Assert.Contains("new { serviceId, definitionId = template.Id, keyFilter }", view);
Assert.Contains("new { serviceId, definitionId = email.Id, keyFilter }", view);
Assert.DoesNotContain("/admin/templates", view);
}
}
@@ -0,0 +1,153 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ChannelTemplateModelTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task MigrationPreservesEmailAndSupportsAllChannelCombinations()
{
var connection = await fixture.CreateDatabaseAsync(legacy: true);
var originalId = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", "[{\"name\":\"Name\",\"required\":true}]");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
await db.Database.MigrateAsync();
await db.Database.MigrateAsync();
Assert.False(db.Database.HasPendingModelChanges());
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var emails = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
var original = await emails.GetAsync("TestService", "Welcome", "en");
Assert.Equal(originalId, original!.Id);
Assert.True(Assert.Single(original.Variables).Required);
await SeedPushAsync(connection, "Welcome");
await SeedPushAsync(connection, "PushOnly");
Assert.Null(await emails.GetAsync("TestService", "PushOnly", "en"));
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "EmailOnly" }, true);
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "PushOnly" }, true);
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF (SELECT count(*) FROM templates) <> 3 OR (SELECT count(*) FROM push_template_definitions) <> 2 THEN
RAISE EXCEPTION 'Unexpected shared catalog or channel count';
END IF;
IF (SELECT variables FROM email_template_definitions WHERE key = 'Welcome') =
(SELECT variables FROM push_template_definitions WHERE key = 'Welcome') THEN
RAISE EXCEPTION 'Channel variables must remain independent';
END IF;
END $$;
""");
}
[Fact]
public async Task DeletingEmailRetainsPushAndBlocksServiceDeletion()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var emails = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
var email = new EmailTemplateDefinition { ServiceName = "TestService", Key = "Shared" };
await emails.SaveDefinitionAsync(email, true);
await SeedPushAsync(connection, "Shared");
Assert.True(await emails.DeleteDefinitionAsync(email.ServiceId == Guid.Empty
? (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync("TestService"))!.Id
: email.ServiceId, email.Key, email.Revision));
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM templates WHERE key = 'Shared') OR
NOT EXISTS (SELECT 1 FROM push_templates) THEN
RAISE EXCEPTION 'Email deletion removed push';
END IF;
END $$;
""");
using var readScope = provider.CreateScope();
var services = readScope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetByNameAsync("TestService"))!;
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.DeleteAsync(service.Id, service.Revision));
}
[Fact]
public async Task LanguageDeletionCountsBothChannelsAndRetainsDefinitions()
{
var connection = await fixture.CreateDatabaseAsync();
await SeedPushAsync(connection, "PushOnly");
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetByNameAsync("TestService"))!;
var language = service.Languages.Single(l => l.Code == "en");
Assert.Equal(1, language.VersionCount);
service.Languages = service.Languages.Where(l => l.Id != language.Id).ToList();
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.SaveAsync(service, false));
await services.RemoveLanguageAsync(service.Id, language.Id, 1, service.Revision);
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM push_templates) OR
NOT EXISTS (SELECT 1 FROM push_template_definitions WHERE key = 'PushOnly') THEN
RAISE EXCEPTION 'Language deletion did not retain the empty push definition';
END IF;
END $$;
""");
}
[Fact]
public async Task DatabaseRejectsDuplicatePushLanguageAndForeignServiceLanguage()
{
var connection = await fixture.CreateDatabaseAsync();
await SeedPushAsync(connection, "PushOnly");
await AssertSqlStateAsync(connection, "23505", """
INSERT INTO push_templates
SELECT gen_random_uuid(), definition_id, service_id, service_language_id, title, body, created, updated
FROM push_templates LIMIT 1;
""");
await TemplateDatabaseFixture.ExecuteAsync(connection, """
INSERT INTO services (id, name, revision, created) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Other', gen_random_uuid(), now());
INSERT INTO service_languages (id, service_id, code, created) VALUES
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'en', now());
""");
await AssertSqlStateAsync(connection, "23503", """
UPDATE push_templates SET service_language_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
""");
}
[Fact]
public async Task RollbackRefusesToDiscardPushDefinitions()
{
var connection = await fixture.CreateDatabaseAsync();
await SeedPushAsync(connection, "PushOnly");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var error = await Assert.ThrowsAsync<PostgresException>(() => db.GetService<IMigrator>()
.MigrateAsync("20261024080457_AddPushChannels"));
Assert.Contains("Cannot roll back channel templates", error.MessageText);
await db.Database.MigrateAsync();
}
private static Task SeedPushAsync(string connection, string key) => TemplateDatabaseFixture.ExecuteAsync(connection, $"""
INSERT INTO templates (id, service_id, key, created)
SELECT gen_random_uuid(), id, '{key}', now() FROM services WHERE name = 'TestService'
ON CONFLICT (service_id, key) DO NOTHING;
INSERT INTO push_template_definitions (id, service_id, key, revision, variables, created)
SELECT gen_random_uuid(), service_id, key, gen_random_uuid(), '[]'::jsonb, now()
FROM templates WHERE key = '{key}';
INSERT INTO push_templates (id, definition_id, service_id, service_language_id, title, body, created)
SELECT gen_random_uuid(), d.id, d.service_id, l.id, 'Title', 'Body', now()
FROM push_template_definitions d JOIN service_languages l ON l.service_id = d.service_id
WHERE d.key = '{key}' AND l.code = 'en';
""");
private static ServiceProvider Provider(string connection) =>
new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
private static async Task AssertSqlStateAsync(string connection, string state, string sql)
{
var error = await Assert.ThrowsAsync<PostgresException>(() => TemplateDatabaseFixture.ExecuteAsync(connection, sql));
Assert.Equal(state, error.SqlState);
}
}
@@ -22,8 +22,8 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
{
(Url: "/admin/services/create", Title: "Create Service", Form: "serviceForm"),
(Url: root + "/edit", Title: "Edit Service", Form: "serviceForm"),
(Url: root + "/templates/create", Title: "Create Email Template", Form: "templateForm"),
(Url: root + "/templates/" + definition.Id, Title: "Edit Email Template", Form: "templateForm")
(Url: root + "/templates/create", Title: "Create Template", Form: "templateForm"),
(Url: root + "/templates/" + definition.Id, Title: "Template: Layout", Form: "templateForm")
};
foreach (var editor in editors)
AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form);
@@ -32,7 +32,7 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
fields["VariablesJson"] = "{invalid";
var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("Invalid variables JSON", errorHtml);
AssertEditorLayout(errorHtml, "Create Email Template", "templateForm");
AssertEditorLayout(errorHtml, "Create Template", "templateForm");
Assert.Contains("<dl class=\"template-service-context\">", errorHtml);
Assert.Contains("<dt>Service</dt>", errorHtml);
Assert.Contains("<dd>TestService</dd>", errorHtml);
@@ -0,0 +1,55 @@
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.Worker.Services.Push;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class PushChannelFormatTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Theory]
[InlineData(1, false, true)]
[InlineData(2, true, true)]
[InlineData(2, false, false)]
[InlineData(3, true, false)]
public async Task TestEndpointSelectsFormatAndValidatesBeforeProviderWithoutChangingChannel(int version, bool hasBinding, bool accepted)
{
await using var factory = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync());
var firebase = new RecordingFirebasePushClient();
await using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
{
services.RemoveAll<IFirebasePushClient>();
services.AddSingleton<IFirebasePushClient>(firebase);
}));
using var client = app.CreateClient();
var html = await client.GetStringAsync("/admin/push-channels/create");
Assert.Contains("Version 2 — notification with a button", html);
var fields = TemplateEditorTestFields.Read(html);
var binding = hasBinding ? Guid.NewGuid() : (Guid?)null;
using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/test-send");
request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]);
request.Content = JsonContent.Create(new
{
firebaseProjectId = "test-project", credentialsJson = PushChannelTestCredentials.Create(),
recipientToken = "test-token", version, bindingId = binding, actionKey = "openBasket", buttonLabel = "Перейти в розділ"
});
var response = await client.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(accepted, result.GetProperty("success").GetBoolean());
Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync());
if (accepted)
{
var sent = Assert.Single(firebase.Messages);
Assert.Equal(version, sent.Version);
Assert.Equal(binding, sent.BindingId);
Assert.Equal("Перейти в розділ", sent.ButtonLabel);
}
else Assert.Empty(firebase.Messages);
using var scope = app.Services.CreateScope();
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync());
}
}
@@ -0,0 +1,173 @@
using System.Net;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class PushTemplateEditorTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task PushOnlyKeyCanAcquireEmailWithoutChangingPush_AndListHasOneRow()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var service = await ReadServiceAsync(connection);
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
fields["Key"] = "PushOnly";
SetPush(fields, "en", "Hello {{Name}}", "A shared item changed.");
fields["VariablesJson"] = "[{\"name\":\"Name\",\"required\":true}]";
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields))).StatusCode);
var missing = await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/email?key=PushOnly");
Assert.Contains("Create Email Template", missing);
var emailFields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/templates/create?templateKey=PushOnly"));
Assert.Equal("PushOnly", emailFields["Key"]);
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/templates/save", new FormUrlEncodedContent(emailFields))).StatusCode);
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var push = await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "PushOnly");
Assert.Equal("Hello {{Name}}", Assert.Single(push!.Languages).Title);
var email = await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync(service.Id, "PushOnly");
Assert.Empty(email!.Variables);
var list = await client.GetStringAsync($"/admin/services/{service.Id}");
Assert.Contains("<th scope=\"col\">Email</th>", list);
Assert.Contains("<th scope=\"col\">Push</th>", list);
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(list, "<td>PushOnly</td>").Count);
}
[Theory]
[InlineData("partial")]
[InlineData("placeholder")]
[InlineData("json")]
[InlineData("catalog")]
public async Task InvalidSaveRetainsInputAndDoesNotCreatePush(string scenario)
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient();
var service = await ReadServiceAsync(connection);
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
fields["Key"] = "Invalid";
SetPush(fields, "en", "Retained title", "Body");
if (scenario == "partial") SetPush(fields, "en", "Retained title", "");
if (scenario == "placeholder") SetPush(fields, "en", "Retained title", "{{Unknown}}");
if (scenario == "json") fields["VariablesJson"] = "{}";
if (scenario == "catalog") fields["Languages[0].LanguageCode"] = "foreign";
var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("Retained title", html);
Assert.Contains("validation-summary-errors", html);
using var provider = Provider(connection);
using var scope = provider.CreateScope();
Assert.Null(await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Invalid"));
}
[Fact]
public async Task MissingTabHasNoSideEffects_StaleSaveIsRejected_AndFinalVersionCanBeRemoved()
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var service = await ReadServiceAsync(connection);
Assert.Contains("Create Push Template", await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
using (var provider = Provider(connection))
using (var scope = provider.CreateScope())
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetDefinitionsAsync(service.Id));
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create?key=Shared"));
SetPush(fields, "uk", "Title", "Body");
await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
var saved = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
var stale = new Dictionary<string, string>(saved);
SetPush(saved, "uk", "Updated title", "Updated body");
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(saved))).StatusCode);
SetPush(stale, "uk", "Retained stale input", "Body");
var error = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(stale));
Assert.Contains("changed after you opened", await error.Content.ReadAsStringAsync());
Assert.Contains("Retained stale input", await error.Content.ReadAsStringAsync());
var current = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
SetPush(current, "uk", "", "");
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(current))).StatusCode);
using var finalProvider = Provider(connection);
using var finalScope = finalProvider.CreateScope();
Assert.Empty((await finalScope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Shared"))!.Languages);
}
[Fact]
public async Task ConfirmedPushDeletionKeepsEmail_AndPostRequiresAntiforgery()
{
var connection = await fixture.CreateDatabaseAsync();
var service = await ReadServiceAsync(connection);
using (var provider = Provider(connection))
using (var scope = provider.CreateScope())
{
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Shared" }, true);
await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().SaveAsync(new PushTemplateDefinition { ServiceId = service.Id, Key = "Shared" }, true);
}
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var route = $"/admin/services/{service.Id}/push-templates/delete";
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(route, new FormUrlEncodedContent(new Dictionary<string, string> { ["Key"] = "Shared" }))).StatusCode);
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync(route + "?key=Shared"));
fields["ConfirmDelete"] = "true";
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(route, new FormUrlEncodedContent(fields))).StatusCode);
using var readProvider = Provider(connection);
using var readScope = readProvider.CreateScope();
Assert.NotNull(await readScope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync(service.Id, "Shared"));
Assert.Null(await readScope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Shared"));
}
private static ServiceProvider Provider(string connection) => new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task LocalizedActionsRoundTripAndInvalidEditsRetainInput(bool invalid)
{
var connection = await fixture.CreateDatabaseAsync();
await using var app = new TemplateWebApplicationFactory(connection);
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
var service = await ReadServiceAsync(connection);
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
fields["Key"] = "WithButtons";
SetPush(fields, "uk", "Потребою поділилися", "Перегляньте деталі");
var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key;
var actionField = codeField[..^"LanguageCode".Length] + "ActionsJson";
fields[actionField] = invalid ? "{\"buttons\":null}" : "{\"defaultAction\":\"openNeed\",\"buttons\":[{\"action\":\"openNeed\",\"label\":\"Переглянути\"}]}";
var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var saved = await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "WithButtons");
if (invalid)
{
Assert.Null(saved);
Assert.Contains("validation-summary-errors", await response.Content.ReadAsStringAsync());
Assert.Equal(fields[actionField], TemplateEditorTestFields.Read(await response.Content.ReadAsStringAsync())[actionField].Trim());
return;
}
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var actions = PushTemplateActionValidation.Parse(Assert.Single(saved!.Languages).ActionsJson);
Assert.Equal("openNeed", actions.DefaultAction);
Assert.Equal("Переглянути", Assert.Single(actions.Buttons).Label);
var reloaded = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=WithButtons"));
var reloadedCodeField = reloaded.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key;
Assert.Equal("openNeed", PushTemplateActionValidation.Parse(reloaded[reloadedCodeField[..^"LanguageCode".Length] + "ActionsJson"]).DefaultAction);
}
private static async Task<NotificationServiceInfo> ReadServiceAsync(string connection)
{
using var provider = Provider(connection);
using var scope = provider.CreateScope();
return (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync("TestService"))!;
}
private static void SetPush(Dictionary<string, string> fields, string code, string title, string body)
{
var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == code).Key;
var prefix = codeField[..^"LanguageCode".Length];
fields[prefix + "Title"] = title;
fields[prefix + "Body"] = body;
}
}
@@ -0,0 +1,22 @@
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.Worker.Services.Push;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class RecordingFirebasePushClient : IFirebasePushClient
{
public List<PushChannelTestMessage> Messages { get; } = [];
public Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
{
Messages.Add(message);
return Task.FromResult(new PushDeliveryOutcome(null));
}
public Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) =>
Task.FromResult(new PushDeliveryOutcome(null));
public Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) =>
throw new NotSupportedException();
}
@@ -116,7 +116,7 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix
var empty = await client.GetStringAsync(baseUrl);
Assert.Contains("No languages configured for this service.", empty);
Assert.Contains("template-key-filter", empty);
Assert.Contains("No email templates for this service yet.", empty);
Assert.Contains("No templates for this service yet.", empty);
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
var withDefinition = await client.GetStringAsync(baseUrl);
@@ -0,0 +1,162 @@
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/services/{serviceId:guid}/push-templates")]
public sealed class AdminPushTemplatesController(IPushTemplateDefinitionRepository pushes,
IEmailTemplateDefinitionRepository emails, INotificationServiceRepository services,
ILogger<AdminPushTemplatesController> logger) : Controller
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
[HttpGet("email")]
public async Task<IActionResult> Email([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null) return NotFound();
if (string.IsNullOrEmpty(key))
return RedirectToAction("Create", "AdminTemplates", new { serviceId, keyFilter });
var email = await emails.GetDefinitionAsync(serviceId, key, ct);
if (email is not null)
return RedirectToAction("Edit", "AdminTemplates", new { serviceId, definitionId = email.Id, keyFilter });
return Missing(service, key, keyFilter, false);
}
[HttpGet("edit")]
public async Task<IActionResult> Edit([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null) return NotFound();
if (string.IsNullOrEmpty(key))
return RedirectToAction(nameof(Create), new { serviceId, keyFilter });
var definition = await pushes.GetAsync(serviceId, key, ct);
return definition is null ? Missing(service, key, keyFilter, true)
: Editor(ToModel(definition, service, keyFilter), service);
}
[HttpGet("create")]
public async Task<IActionResult> Create([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null) return NotFound();
if (key is not null && await pushes.GetAsync(serviceId, key, ct) is not null)
return RedirectToAction(nameof(Edit), new { serviceId, key, keyFilter });
return Editor(new PushTemplateEditViewModel
{
ServiceId = serviceId, ServiceName = service.Name, Key = key ?? "", KeyFilter = keyFilter,
LockKey = key is not null && await emails.GetDefinitionAsync(serviceId, key, ct) is not null
}, service);
}
[HttpPost("save"), ValidateAntiForgeryToken]
public async Task<IActionResult> Save([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
if (service is null || serviceId != model.ServiceId) return NotFound();
model.ServiceName = service.Name;
var persisted = await pushes.GetAsync(serviceId, model.Key, ct);
if (model.Id is not null && persisted?.Id != model.Id) return NotFound();
model.LockKey = model.Id is not null || await emails.GetDefinitionAsync(serviceId, model.Key, ct) is not null;
SetSavedStatuses(model, persisted);
ValidateCatalog(model, service);
if (!ModelState.IsValid) return Editor(model, service);
return await SaveModelAsync(model, service, ct);
}
private async Task<IActionResult> SaveModelAsync(PushTemplateEditViewModel model, NotificationServiceInfo service, CancellationToken ct)
{
try
{
await pushes.SaveAsync(new PushTemplateDefinition
{
Id = model.Id ?? Guid.Empty, ServiceId = service.Id, Key = model.Key, Revision = model.Revision,
Variables = EmailTemplateValidation.ParseVariables(model.VariablesJson),
Languages = model.Languages.Where(l => l.HasContent).Select(l => new PushTemplateTranslation
{
LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson
}).ToList()
}, model.IsNew, ct);
return RedirectToAction("Index", "AdminTemplates", new { serviceId = service.Id, key = model.KeyFilter });
}
catch (JsonException) { ModelState.AddModelError("VariablesJson", "Invalid variables JSON. Use name and required properties."); }
catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); }
catch (Exception error) when (error is not OperationCanceledException)
{
logger.LogError(error, "Failed to save push template definition");
ModelState.AddModelError("", "Could not save the push template. Your entered values are retained; try again.");
}
return Editor(model, service);
}
[HttpGet("delete")]
public async Task<IActionResult> ConfirmDelete([FromRoute] Guid serviceId, string key, string? keyFilter, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await pushes.GetAsync(serviceId, key, ct);
return service is null || definition is null ? NotFound() : View("Delete", ToModel(definition, service, keyFilter));
}
[HttpPost("delete"), ValidateAntiForgeryToken]
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct)
{
var service = await services.GetAsync(serviceId, ct);
var definition = await pushes.GetAsync(serviceId, model.Key, ct);
if (service is null || definition is null) return NotFound();
if (!model.ConfirmDelete || !ModelState.IsValid)
ModelState.AddModelError("", "Confirm deletion of this push template to continue.");
else
{
try
{
await pushes.DeleteAsync(serviceId, model.Key, model.Revision, ct);
return RedirectToAction("Index", "AdminTemplates", new { serviceId, key = model.KeyFilter });
}
catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); }
}
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList();
ModelState.Clear();
foreach (var error in errors) ModelState.AddModelError("", error);
return View("Delete", ToModel(definition, service, model.KeyFilter));
}
private IActionResult Missing(NotificationServiceInfo service, string key, string? keyFilter, bool isPush) => View("Missing", new TemplateChannelNavigationViewModel
{
ServiceId = service.Id, ServiceName = service.Name, Key = key, KeyFilter = keyFilter, IsPush = isPush
});
private IActionResult Editor(PushTemplateEditViewModel model, NotificationServiceInfo service)
{
foreach (var language in service.Languages)
if (!model.Languages.Any(l => l.LanguageCode == language.Code))
model.Languages.Add(new PushTemplateLanguageEditViewModel { LanguageCode = language.Code });
return View("Edit", model);
}
private void ValidateCatalog(PushTemplateEditViewModel model, NotificationServiceInfo service)
{
var codes = model.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
if (codes.Count != model.Languages.Count || !codes.SetEquals(service.Languages.Select(l => l.Code)))
ModelState.AddModelError("", "The service language catalog changed or the submitted tabs are invalid. Reload before saving.");
}
private static void SetSavedStatuses(PushTemplateEditViewModel model, PushTemplateDefinition? persisted)
{
var codes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal) ?? [];
foreach (var language in model.Languages) language.IsPresent = codes.Contains(language.LanguageCode);
}
private static PushTemplateEditViewModel ToModel(PushTemplateDefinition definition, NotificationServiceInfo service, string? keyFilter) => new()
{
Id = definition.Id, ServiceId = service.Id, ServiceName = service.Name, Key = definition.Key,
Revision = definition.Revision, KeyFilter = keyFilter, LockKey = true,
VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions),
Languages = definition.Languages.Select(l => new PushTemplateLanguageEditViewModel
{
LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson, IsPresent = true
}).ToList()
};
}
@@ -12,7 +12,7 @@ namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/services/{serviceId:guid}/templates")]
public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions,
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services) : Controller
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services, IPushTemplateDefinitionRepository pushes) : Controller
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
@@ -27,6 +27,8 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
ModelState.AddModelError("", previousError);
try
{
model.PushDefinitions = (await pushes.GetDefinitionsAsync(serviceId, ct))
.Where(d => string.IsNullOrWhiteSpace(key) || d.Key == key).ToList();
model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct))
.Where(d => d.ServiceId == serviceId).ToList();
}
@@ -39,12 +41,12 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
}
[HttpGet("create")]
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct)
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct, [FromQuery] string? templateKey = null)
{
var service = await services.GetAsync(serviceId, ct);
return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel
{
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter, Key = templateKey ?? "", LockKey = !string.IsNullOrEmpty(templateKey)
}, service);
}
@@ -72,6 +74,7 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
var persisted = model.Id is Guid definitionId ? await OwnedDefinitionAsync(service, definitionId, ct) : null;
if (model.Id is not null && persisted is null)
return NotFound();
model.LockKey = model.Id is not null || await pushes.GetAsync(serviceId, model.Key, ct) is not null;
var presentCodes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
foreach (var language in model.Languages)
language.IsPresent = presentCodes?.Contains(language.LanguageCode) == true;
@@ -16,6 +16,7 @@ public sealed class EmailTemplateEditViewModel
public List<EmailTemplateLanguageEditViewModel> Languages { get; set; } = [];
public int ActiveLanguageIndex { get; set; }
public string? KeyFilter { get; set; }
public bool LockKey { get; set; }
public bool IsNew => Id == null;
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
public string PageTitle => IsNew ? "Create Template" : "Template: " + Key;
}
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class PushTemplateEditViewModel
{
public Guid? Id { get; set; }
public Guid ServiceId { get; set; }
public Guid Revision { get; set; }
public string ServiceName { get; set; } = "";
[Required, StringLength(100)]
public string Key { get; set; } = "";
[Required]
public string VariablesJson { get; set; } = "[]";
public List<PushTemplateLanguageEditViewModel> Languages { get; set; } = [];
public int ActiveLanguageIndex { get; set; }
public string? KeyFilter { get; set; }
public bool LockKey { get; set; }
public bool ConfirmDelete { get; set; }
public bool IsNew => Id is null;
public string PageTitle => IsNew ? "Create Template" : "Template: " + Key;
}
@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class PushTemplateLanguageEditViewModel : IValidatableObject
{
[Required, StringLength(10)]
public string LanguageCode { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string Title { get; set; } = "";
[DisplayFormat(ConvertEmptyStringToNull = false)]
public string Body { get; set; } = "";
[Required]
public string ActionsJson { get; set; } = "{}";
[BindNever]
public bool IsPresent { get; set; }
public bool HasContent => !string.IsNullOrWhiteSpace(Title) || !string.IsNullOrWhiteSpace(Body) || HasActions();
private bool HasActions()
{
try
{
var actions = DAL.Abstract.Templates.PushTemplateActionValidation.Parse(ActionsJson);
return actions.DefaultAction is not null || actions.Buttons.Count > 0;
}
catch (DAL.Abstract.Templates.EmailTemplateWriteException) { return true; }
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!HasContent) yield break;
if (string.IsNullOrWhiteSpace(Title))
yield return new ValidationResult("Title is required for a populated language.", [nameof(Title)]);
if (string.IsNullOrWhiteSpace(Body))
yield return new ValidationResult("Body is required for a populated language.", [nameof(Body)]);
}
}
@@ -6,5 +6,6 @@ public sealed class ServiceTemplatesViewModel
{
public required NotificationServiceInfo Service { get; init; }
public string? KeyFilter { get; init; }
public IReadOnlyList<PushTemplateDefinition> PushDefinitions { get; set; } = [];
public IReadOnlyList<EmailTemplateDefinition> Definitions { get; set; } = [];
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class TemplateChannelNavigationViewModel
{
public Guid ServiceId { get; init; }
public string ServiceName { get; init; } = "";
public string Key { get; init; } = "";
public string? KeyFilter { get; init; }
public bool IsPush { get; init; }
}
@@ -73,10 +73,26 @@
</div>
<div class="modal-body">
<p class="text-muted small">Tests the current form settings without saving. Blank credentials use the saved credentials only when the Firebase project is unchanged.</p>
<p class="text-muted small">Sends a neutral notification directly through Firebase. This tests the channel, not Invemory account binding, navigation or background handling. Use your own test device only.</p>
<label for="pushTestVersion" class="form-label fw-semibold">Test format</label>
<select id="pushTestVersion" class="form-select mb-2" aria-describedby="pushTestFormatHelp">
<option value="1">Legacy — simple notification</option>
<option value="2">Version 2 — notification with a button</option>
</select>
<p id="pushTestFormatHelp" class="text-muted small">Legacy sends the existing neutral notification. Version 2 tests the action payload and requires a client that supports buttons. Firebase acceptance alone does not confirm display or navigation. Use your own test device only.</p>
<label for="pushRecipientToken" class="form-label fw-semibold">Recipient token</label>
<textarea id="pushRecipientToken" class="form-control font-monospace" rows="3" maxlength="4096" autocomplete="off" spellcheck="false"></textarea>
<div class="form-text">Not saved as channel configuration. Cleared after the test or when this dialog closes.</div>
<fieldset id="pushButtonTestFields" class="mt-3" hidden>
<legend class="fs-6">Button test</legend>
<label for="pushTestBindingId" class="form-label">Current device binding ID</label>
<input id="pushTestBindingId" class="form-control mb-2" autocomplete="off" spellcheck="false" />
<div class="form-text mb-2">Use the actual binding of the recipient installation. An invented or outdated binding will be rejected by the app.</div>
<label for="pushTestActionKey" class="form-label">Section action key</label>
<input id="pushTestActionKey" class="form-control mb-2" maxlength="100" placeholder="Action supported by your app" spellcheck="false" />
<label for="pushTestButtonLabel" class="form-label">Button label</label>
<input id="pushTestButtonLabel" class="form-control" maxlength="100" value="Перейти в розділ" />
<div class="form-text">The button and notification tap use the same action. No object ID is needed for a section.</div>
</fieldset>
<div id="pushSendResult" class="alert d-none mt-3" role="status" aria-live="polite"></div>
</div>
<div class="modal-footer">
@@ -101,6 +117,8 @@
const authorizationButton = document.getElementById('pushAuthorizationBtn');
const sendButton = document.getElementById('pushSendBtn');
const modalButton = document.getElementById('pushTestModalBtn');
const version = document.getElementById('pushTestVersion');
const binding = document.getElementById('pushTestBindingId');
let busy = false;
function showResult(target, success, message) {
@@ -114,7 +132,11 @@
channelId: id === '00000000-0000-0000-0000-000000000000' ? null : id,
firebaseProjectId: form.querySelector('[name="FirebaseProjectId"]').value,
credentialsJson: form.querySelector('[name="CredentialsJson"]').value,
recipientToken: send ? token.value.trim() : null
recipientToken: send ? token.value.trim() : null,
version: Number(version.value),
bindingId: send && version.value === '2' ? binding.value.trim() || null : null,
actionKey: document.getElementById('pushTestActionKey').value,
buttonLabel: document.getElementById('pushTestButtonLabel').value
};
}
@@ -152,10 +174,14 @@
authorizationButton.addEventListener('click', () => runTest(false));
sendButton.addEventListener('click', () => runTest(true));
version.addEventListener('change', () => {
document.getElementById('pushButtonTestFields').hidden = version.value !== '2';
document.getElementById('pushSendResult').className = 'alert d-none';
});
modalButton.addEventListener('click', () => {
document.getElementById('pushSendResult').className = 'alert d-none';
bootstrap.Modal.getOrCreateInstance(modal).show();
});
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; });
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; binding.value = ''; });
});
</script>
@@ -0,0 +1,12 @@
@model HrynCo.NotificationService.Web.Controllers.Admin.ViewModels.PushTemplateEditViewModel
@{ ViewData["Title"] = "Delete Push Template"; }
<h1 class="h3">Delete Push Template</h1>
<p>Delete the Push template <strong>@Model.Key</strong> and its @Model.Languages.Count language versions from <strong>@Model.ServiceName</strong>? The Email template is retained.</p>
<form method="post" asp-action="Delete" asp-route-serviceId="@Model.ServiceId">
@Html.AntiForgeryToken()
<input asp-for="Key" type="hidden" /><input asp-for="Revision" type="hidden" /><input asp-for="KeyFilter" type="hidden" />
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
<div class="form-check my-3"><input asp-for="ConfirmDelete" class="form-check-input" /><label asp-for="ConfirmDelete" class="form-check-label">Confirm deletion of this Push template and its translations</label></div>
<button class="btn btn-danger" type="submit">Delete Push Template</button>
<a class="btn btn-secondary" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })">Cancel</a>
</form>
@@ -0,0 +1,111 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model PushTemplateEditViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = Model.PageTitle;
ViewData["EditorTitle"] = Model.PageTitle;
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="AdminPushTemplates" asp-route-serviceId="@Model.ServiceId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
<input asp-for="Revision" type="hidden" />
<input asp-for="ServiceId" type="hidden" /><input asp-for="ServiceName" type="hidden" />
<input asp-for="KeyFilter" type="hidden" />
<input type="hidden" id="ActiveLanguageIndex" name="ActiveLanguageIndex" value="@active" />
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<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.LockKey)" />
</div>
</div>
<partial name="_TemplateChannelNavigation" model="@(new TemplateChannelNavigationViewModel { ServiceId = Model.ServiceId, Key = Model.Key, KeyFilter = Model.KeyFilter, IsPush = true })" />
<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="14"></textarea>
<div class="form-text">Push variables, shared across its languages: <code>[{"name":"UserName","required":true}]</code></div>
</div>
<p class="text-muted">Every configured service language has a tab. Present and Missing show saved versions. To remove a language version, clear Title and Body and set Actions to {}. 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++)
{
var language = Model.Languages[i];
<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">
<span>@language.LanguageCode</span>
<span class="template-language-status @(language.IsPresent ? "template-language-present" : "template-language-missing")">@(language.IsPresent ? "Present" : "Missing")</span>
</button>
}
</div>
<div id="languagePanes">
@for (var i = 0; i < Model.Languages.Count; i++)
{
<section class="language-pane" id="language-pane-@i" role="tabpanel" aria-labelledby="language-tab-@i" hidden="@(i != active)">
<input asp-for="Languages[i].LanguageCode" type="hidden" class="language-code" />
<div class="mb-3">
<label asp-for="Languages[i].Title" class="form-label fw-semibold">Title</label>
<input asp-for="Languages[i].Title" class="form-control" data-content="title" />
</div>
<div class="mb-3">
<label asp-for="Languages[i].Body" class="form-label fw-semibold">Body</label>
<textarea asp-for="Languages[i].Body" class="form-control font-monospace" rows="8" data-content="body"></textarea>
</div>
<div class="mb-3">
<label asp-for="Languages[i].ActionsJson" class="form-label fw-semibold">Actions (JSON)</label>
<textarea asp-for="Languages[i].ActionsJson" class="form-control font-monospace" rows="6" data-content="actions"></textarea>
<div class="form-text">Labels use this language. Action keys must match the message metadata; object IDs are optional for section navigation. Up to three buttons. Example: <code>{"defaultAction":"openSection","buttons":[{"action":"openSection","label":"Перейти в розділ"}]}</code>. Use <code>{}</code> for no actions.</div>
</div>
</section>
}
</div>
</div>
<div class="template-preview-panel mb-3">
<div class="template-preview-panel-header">
<div>
<div class="template-preview-title">Selected language preview: <span id="previewLanguage"></span></div>
<div class="template-preview-subtitle">Preview uses the Push variables. Content is plain text; this does not send a notification.</div>
</div>
<span id="previewStatus" class="badge text-bg-secondary" role="status">Ready</span>
</div>
<div class="template-preview-body">
<div class="template-preview-source template-preview-section-block">
<div class="template-preview-section-title">Sample values</div>
<div id="previewVariables" class="template-preview-variables"></div>
</div>
<div class="template-preview-output">
<div class="template-preview-section-title">Rendered title</div>
<div id="previewTitle" class="template-preview-subject"></div>
<div class="template-preview-section-title mt-3">Rendered body</div>
<pre id="previewBody" class="template-preview-text mb-0"></pre>
<div class="template-preview-section-title mt-3">Buttons</div>
<div id="previewActions" class="d-flex flex-wrap gap-2" aria-label="Button preview"></div>
<div id="previewDefaultAction" class="form-text"></div>
</div>
</div>
</div>
</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/template-channel-navigation.js" asp-append-version="true"></script>
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
}
@@ -0,0 +1,23 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model TemplateChannelNavigationViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = "Template: " + Model.Key;
ViewData["EditorTitle"] = ViewData["Title"];
var channel = Model.IsPush ? "Push" : "Email";
}
@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">Template</li></ol></nav>
}
<dl class="template-service-context"><dt>Service</dt><dd>@Model.ServiceName</dd><dt>Template Key</dt><dd>@Model.Key</dd></dl>
<partial name="_TemplateChannelNavigation" model="Model" />
<div class="empty-state"><p>@channel template has not been created for this key.</p>
@if (Model.IsPush) {
<a class="btn btn-primary" asp-action="Create" asp-route-serviceId="@Model.ServiceId" asp-route-key="@Model.Key" asp-route-keyFilter="@Model.KeyFilter">Create Push Template</a>
} else {
<a class="btn btn-primary" asp-action="Create" asp-controller="AdminTemplates" asp-route-serviceId="@Model.ServiceId" asp-route-templateKey="@Model.Key" asp-route-keyFilter="@Model.KeyFilter">Create Email Template</a>
}
</div>
@section FormActions {
<a href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Back to Templates</a>
}
@@ -2,16 +2,16 @@
@model EmailTemplateDefinitionDeleteViewModel
@{ ViewData["Title"] = "Delete email template"; var returnUrl = Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter }); }
<div class="admin-delete-confirmation">
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="@returnUrl">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">Delete template</li></ol></nav>
<nav aria-label="Breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/admin/services">Services</a></li><li class="breadcrumb-item"><a href="@returnUrl">@Model.ServiceName</a></li><li class="breadcrumb-item active" aria-current="page">Delete Email template</li></ol></nav>
<h2>Delete template @Model.Key?</h2>
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
<div class="alert alert-danger">This permanently deletes the definition, shared variables and <strong>@Model.LanguageCodes.Count translation(s)</strong> from service @Model.ServiceName.</div>
<div class="alert alert-danger">This permanently deletes the Email definition, its shared variables and <strong>@Model.LanguageCodes.Count translation(s)</strong> from service @Model.ServiceName.</div>
<p>Languages: @(Model.LanguageCodes.Count == 0 ? "No translations" : string.Join(", ", Model.LanguageCodes))</p>
<p>The service, its language catalog and email channels are retained.</p>
<p>The Push template, service, language catalog and delivery channels are retained.</p>
<form asp-action="DeleteDefinition" asp-route-serviceId="@Model.ServiceId" asp-route-definitionId="@Model.DefinitionId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Revision" type="hidden" /><input asp-for="KeyFilter" 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.Key and its @Model.LanguageCodes.Count translation(s).</label></div>
<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 the Email template @Model.Key and its @Model.LanguageCodes.Count translation(s).</label></div>
<div class="d-flex flex-wrap gap-2"><button type="submit" class="btn btn-danger">Delete template</button><a class="btn btn-secondary" href="@returnUrl">Cancel</a></div>
</form>
</div>
@@ -29,13 +29,14 @@
</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)" />
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew || Model.LockKey)" />
</div>
</div>
<partial name="_TemplateChannelNavigation" model="@(new TemplateChannelNavigationViewModel { ServiceId = Model.ServiceId, Key = Model.Key, KeyFilter = Model.KeyFilter })" />
<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="14"></textarea>
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
<div class="form-text">Email variables, shared across its languages: <code>[{"name":"UserName","required":true}]</code></div>
</div>
<p class="text-muted">Every configured service language has a tab. Present and Missing show saved versions. Leave all three content fields blank for an unused language; clear them to remove an existing version. Shared variables and the template remain.</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> }
@@ -102,6 +103,7 @@
<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/template-channel-navigation.js" asp-append-version="true"></script>
<script src="~/js/email-template-preview.js" asp-append-version="true"></script>
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
}
@@ -25,7 +25,7 @@
}
</div>
<div class="page-header">
<h3 class="h4">Email Templates</h3>
<h3 class="h4">Templates</h3>
<a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a>
</div>
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
@@ -40,12 +40,13 @@
</div>
</form>
</th>
<th scope="col">Languages</th>
<th scope="col">Email</th>
<th scope="col">Push</th>
<th scope="col" class="template-actions">Action</th>
</tr></thead><tbody>
@if (Model.Definitions.Count == 0)
@if (Model.Definitions.Count == 0 && Model.PushDefinitions.Count == 0)
{
<tr><td colspan="3"><div class="empty-state">
<tr><td colspan="4"><div class="empty-state">
@if (!ViewData.ModelState.IsValid)
{
<p class="mb-0">Templates are unavailable. Review the error and try again.</p>
@@ -56,18 +57,21 @@
}
else
{
<i class="bi bi-envelope" aria-hidden="true"></i><p class="mt-2 mb-0">No email templates for this service yet.</p><a asp-action="Create" asp-route-serviceId="@serviceId" class="btn btn-primary btn-sm mt-3">Create First Template</a>
<i class="bi bi-envelope" aria-hidden="true"></i><p class="mt-2 mb-0">No templates for this service yet.</p><a asp-action="Create" asp-route-serviceId="@serviceId" class="btn btn-primary btn-sm mt-3">Create First Template</a>
}
</div></td></tr>
}
@foreach (var template in Model.Definitions)
@foreach (var templateKey in Model.Definitions.Select(d => d.Key).Concat(Model.PushDefinitions.Select(d => d.Key)).Distinct(StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal))
{
var editUrl = Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = template.Id, keyFilter });
var presentCodes = template.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
var email = Model.Definitions.SingleOrDefault(d => d.Key == templateKey);
var push = Model.PushDefinitions.SingleOrDefault(d => d.Key == templateKey);
var editUrl = email is null ? Url.Action("Edit", "AdminPushTemplates", new { serviceId, key = templateKey, keyFilter }) : Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = email.Id, keyFilter });
var presentCodes = email?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal) ?? [];
<tr>
<td>@template.Key</td>
<td>@templateKey</td>
<td>
@if (Model.Service.Languages.Count == 0)
@if (email is null) { <span>Not configured</span> }
else if (Model.Service.Languages.Count == 0)
{
<span>No configured languages</span>
}
@@ -84,7 +88,22 @@
</ul>
}
</td>
<td class="template-actions"><div class="list-action-links"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @template.Key">Edit</a><a asp-action="ConfirmDefinitionDelete" asp-route-serviceId="@serviceId" asp-route-definitionId="@template.Id" asp-route-keyFilter="@keyFilter" class="btn btn-outline-danger btn-sm" aria-label="Delete template @template.Key">Delete</a></div></td>
<td>
@if (push is null) { <span>Not configured</span> }
else if (Model.Service.Languages.Count == 0) { <span>No configured languages</span> }
else {
var pushCodes = push.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
<ul class="template-language-statuses">
@foreach (var language in Model.Service.Languages) {
var present = pushCodes.Contains(language.Code);
<li class="template-language-status @(present ? "template-language-present" : "template-language-missing")" data-language-code="@language.Code" data-state="@(present ? "present" : "missing")"><span>@language.Code:</span><span>@(present ? "Present" : "Missing")</span></li>
}
</ul>
}
</td>
<td class="template-actions"><div class="list-action-links"><a href="@editUrl" class="btn btn-outline-primary btn-sm" aria-label="Edit template @templateKey">Edit</a>@if (email is not null) { <a asp-action="ConfirmDefinitionDelete" asp-route-serviceId="@serviceId" asp-route-definitionId="@email.Id" asp-route-keyFilter="@keyFilter" class="btn btn-outline-danger btn-sm" aria-label="Delete Email template @templateKey">Delete</a> }
@if (push is not null) { <a asp-action="ConfirmDelete" asp-controller="AdminPushTemplates" asp-route-serviceId="@serviceId" asp-route-key="@templateKey" asp-route-keyFilter="@keyFilter" class="btn btn-outline-danger btn-sm">Delete Push</a> }
</div></td>
</tr>
}
</tbody></table></div>
@@ -14,7 +14,7 @@
<link href="/lib/bootstrap/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet" />
<link href="/css/admin.css" rel="stylesheet" />
<link href="~/css/admin.css" rel="stylesheet" asp-append-version="true" />
</head>
<body>
<nav class="navbar navbar-dark bg-dark admin-navbar">
@@ -0,0 +1,7 @@
@model HrynCo.NotificationService.Web.Controllers.Admin.ViewModels.TemplateChannelNavigationViewModel
<nav class="nav nav-tabs mb-3 template-channel-tabs" aria-label="Template channels">
<a class="nav-link @(!Model.IsPush ? "active" : "")" aria-current="@(!Model.IsPush ? "page" : null)" data-template-channel
href="@Url.Action("Email", "AdminPushTemplates", new { serviceId = Model.ServiceId, key = Model.Key, keyFilter = Model.KeyFilter })">Email</a>
<a class="nav-link @(Model.IsPush ? "active" : "")" aria-current="@(Model.IsPush ? "page" : null)" data-template-channel
href="@Url.Action("Edit", "AdminPushTemplates", new { serviceId = Model.ServiceId, key = Model.Key, keyFilter = Model.KeyFilter })">Push</a>
</nav>
@@ -578,3 +578,11 @@ body {
}
textarea.form-control { resize: vertical; }
/* Keep channel/language comparisons readable inside the responsive wrapper. */
.service-template-grid { min-width: 960px; }
.service-template-grid .template-language-status { white-space: nowrap; }
.template-channel-tabs .nav-link { min-width: 80px; text-align: center; }
.template-unsaved-dialog { border: 1px solid #cfd8e3; border-radius: .5rem; padding: 1.5rem; max-width: min(440px, calc(100vw - 32px)); }
.template-unsaved-dialog::backdrop { background: rgb(0 0 0 / 40%); }
@@ -5,7 +5,7 @@
const activeField = document.getElementById('ActiveLanguageIndex');
const variablesField = document.getElementById('VariablesJson');
const samples = document.getElementById('previewVariables');
let dirty = false;
let dirty = !!form.querySelector('.validation-summary-errors');
const sampleValues = new Map();
function escapeHtml(value) {
@@ -77,29 +77,52 @@
}
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 text.replace(/\{\{([^{}]+)\}\}/g, (placeholder, name) => {
if (!sampleValues.has(name)) return placeholder;
const value = sampleValues.get(name);
return html ? escapeHtml(value) : 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;
const push = !!document.getElementById('previewTitle');
document.getElementById('previewLanguage').textContent = pane?.querySelector('.language-code').value ?? '(no versions)';
const heading = pane?.querySelector(push ? '[data-content="title"]' : '[data-content="subject"]')?.value ?? '';
const body = pane?.querySelector(push ? '[data-content="body"]' : '[data-content="text"]')?.value ?? '';
document.getElementById(push ? 'previewTitle' : 'previewSubject').textContent = interpolate(heading);
document.getElementById(push ? 'previewBody' : 'previewText').textContent = interpolate(body);
updateHtmlPreview(pane);
updateActionsPreview(pane);
}
function updateActionsPreview(pane) {
const container = document.getElementById('previewActions');
if (!container) return;
container.replaceChildren();
const hint = document.getElementById('previewDefaultAction');
try {
const actions = JSON.parse(pane?.querySelector('[data-content="actions"]')?.value || '{}');
if (!actions || (actions.buttons !== undefined && !Array.isArray(actions.buttons))) throw new Error();
(actions.buttons ?? []).forEach(item => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-outline-primary btn-sm';
button.textContent = interpolate(item.label);
button.title = `Action: ${item.action}. Preview only.`;
container.append(button);
});
hint.textContent = actions.defaultAction ? `Notification tap: ${actions.defaultAction}` : 'No notification tap action configured.';
} catch {
hint.textContent = 'Invalid actions JSON. Use defaultAction and buttons with action and label.';
}
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>`;
}
function updateHtmlPreview(pane) {
const frame = document.getElementById('previewHtmlFrame');
if (!frame) return;
const html = interpolate(pane?.querySelector('[data-content="html"]')?.value ?? '', true);
frame.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 => {
@@ -126,5 +149,25 @@
if (dirty) { event.preventDefault(); event.returnValue = ''; }
});
const navigationDialog = document.createElement('dialog');
navigationDialog.className = 'template-unsaved-dialog';
navigationDialog.setAttribute('aria-labelledby', 'unsaved-title');
navigationDialog.innerHTML = '<h2 id="unsaved-title" class="h5">Unsaved changes</h2><p>Save this channel before leaving, or discard its unsaved changes.</p><div class="d-flex flex-wrap gap-2"><button type="button" class="btn btn-primary" data-keep-editing>Keep editing</button><button type="button" class="btn btn-outline-danger" data-discard-changes>Discard changes</button></div>';
document.body.append(navigationDialog);
let pendingNavigation;
navigationDialog.querySelector('[data-keep-editing]').addEventListener('click', () => navigationDialog.close());
navigationDialog.querySelector('[data-discard-changes]').addEventListener('click', () => {
dirty = false;
navigationDialog.close();
window.location.assign(pendingNavigation);
});
document.addEventListener('click', event => {
const link = event.target.closest('a');
if (!dirty || !link || link.target === '_blank') return;
event.preventDefault();
pendingNavigation = link.href;
navigationDialog.showModal();
});
renderSamples();
})();
@@ -0,0 +1,10 @@
(() => {
const key = document.getElementById('Key');
document.querySelectorAll('[data-template-channel]').forEach(link => {
link.addEventListener('click', () => {
const url = new URL(link.href);
url.searchParams.set('key', key?.value ?? url.searchParams.get('key') ?? '');
link.href = url.href;
});
});
})();
@@ -4,24 +4,40 @@ using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed class FcmPushSender(
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client) : IPushSender
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client,
IPushTemplateDefinitionRepository templates) : IPushSender
{
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken)
{
if (!PushMessageValidator.IsSupportedVersion(data.Version))
return new PushDeliveryOutcome("Unsupported push payload version. Supported versions are 1 and 2.");
if (!PushMessageValidator.IsValidContent(data)) return new PushDeliveryOutcome("Invalid push content for the requested version.");
if (data.ExpiresAt <= DateTimeOffset.UtcNow) return new PushDeliveryOutcome("Push expired.");
var service = await services.GetByNameAsync(data.ServiceName, cancellationToken);
if (service is null) return new PushDeliveryOutcome("Push service is not configured.");
RenderedPushMessageData rendered;
try { rendered = await RenderAsync(service.Id, data, cancellationToken); }
catch (EmailTemplateWriteException) { return new PushDeliveryOutcome("Push template data or action configuration is invalid."); }
var channel = await channels.GetActiveForServiceAsync(service.Id, cancellationToken);
if (channel is null) return new PushDeliveryOutcome("No active push channel is configured for this service.");
var credentials = await channels.GetCredentialsAsync(channel.Id, cancellationToken);
return await SendWithCredentialsAsync(messageId, data, channel.FirebaseProjectId, credentials, cancellationToken);
return await SendWithCredentialsAsync(messageId, rendered, channel.FirebaseProjectId, credentials, cancellationToken);
}
private async Task<PushDeliveryOutcome> SendWithCredentialsAsync(Guid messageId, SendPushMessageData data,
private async Task<RenderedPushMessageData> RenderAsync(Guid serviceId, SendPushMessageData data, CancellationToken ct)
{
if (data.Version == 1) return new RenderedPushMessageData(data, data.Title!, data.Body!, null, []);
var template = await templates.GetAsync(serviceId, data.TemplateKey!, ct)
?? throw new EmailTemplateWriteException("Push template is not configured.", false);
return PushTemplateRenderer.Render(template, data);
}
private async Task<PushDeliveryOutcome> SendWithCredentialsAsync(Guid messageId, RenderedPushMessageData data,
string projectId, string? json, CancellationToken ct)
{
try
@@ -3,42 +3,91 @@ namespace HrynCo.NotificationService.Worker.Services.Push;
using FirebaseAdmin.Messaging;
using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
using HrynCo.NotificationService.Contracts.Messages;
using System.Text;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class FirebasePushClient : IFirebasePushClient
{
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
{
var remaining = data.ExpiresAt - DateTimeOffset.UtcNow;
if (!PushMessageValidator.IsSupportedVersion(data.Delivery.Version))
return new PushDeliveryOutcome("Unsupported push payload version. Supported versions are 1 and 2.");
var remaining = data.Delivery.ExpiresAt - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) return new PushDeliveryOutcome("Push expired.");
var payload = BuildPayload(messageId, data);
if (data.Delivery.Version == 2 && Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(payload)) > 3500)
return new PushDeliveryOutcome("Rendered push payload is too large.");
return await SendMessageAsync(new Message
{
Token = data.Token,
Token = data.Delivery.Token,
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining },
Data = new Dictionary<string, string>
{
["version"] = "1", ["messageId"] = messageId.ToString("D"),
["bindingId"] = data.BindingId.ToString("D"), ["title"] = data.Title,
["body"] = data.Body, ["route"] = data.Route,
["expiresAt"] = data.ExpiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
}
Data = payload
}, credentials, cancellationToken);
}
public Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken) => SendMessageAsync(new Message
internal static Dictionary<string, string> BuildPayload(Guid messageId, RenderedPushMessageData data)
{
var payload = BasePayload(messageId, data.Delivery.BindingId, data.Delivery.ExpiresAt, data.Title, data.Body);
switch (data.Delivery.Version)
{
Token = token,
Notification = new Notification
{
Title = "Notification Service test",
Body = "This is a test notification from the channel administration page."
},
case 1:
payload["version"] = "1";
payload["route"] = data.Delivery.Route;
break;
case 2:
AddActions(payload, data.DefaultAction, data.Buttons);
break;
default: throw new ArgumentOutOfRangeException(nameof(data), "Unsupported push payload version.");
}
return payload;
}
private static Dictionary<string, string> BasePayload(Guid messageId, Guid bindingId, DateTimeOffset expiresAt, string title, string body) => new()
{
["messageId"] = messageId.ToString("D"), ["bindingId"] = bindingId.ToString("D"),
["title"] = title, ["body"] = body,
["expiresAt"] = expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
};
private static void AddActions(Dictionary<string, string> payload, RenderedPushAction? defaultAction, IReadOnlyList<RenderedPushAction> buttons)
{
payload["version"] = "2";
payload["actions"] = JsonSerializer.Serialize(new { defaultAction, buttons }, JsonOptions);
}
public Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken)
{
if (message.Validate() is { } error) return Task.FromResult(new PushDeliveryOutcome(error));
return SendMessageAsync(BuildTestMessage(message), credentials, cancellationToken);
}
internal static Message BuildTestMessage(PushChannelTestMessage test)
{
if (test.Validate() is { } error) throw new ArgumentException(error, nameof(test));
const string title = "Notification Service test";
const string body = "This is a test notification from the channel administration page.";
var message = new Message
{
Token = test.Token,
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = TimeSpan.FromMinutes(5) }
}, credentials, cancellationToken);
};
if (test.Version == 1) message.Notification = new Notification { Title = title, Body = body };
else
{
var payload = BasePayload(Guid.NewGuid(), test.BindingId!.Value, DateTimeOffset.UtcNow.AddMinutes(5), title, body);
AddActions(payload, new RenderedPushAction(test.ActionKey!), [new RenderedPushAction(test.ActionKey!, Label: test.ButtonLabel)]);
message.Data = payload;
}
return message;
}
public async Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken)
@@ -7,7 +7,7 @@ namespace HrynCo.NotificationService.Worker.Services.Push;
public interface IFirebasePushClient
{
Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
}
@@ -0,0 +1,31 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed record PushChannelTestMessage
{
public required string Token { get; init; }
public int Version { get; init; } = 1;
public Guid? BindingId { get; init; }
public string? ActionKey { get; init; }
public string? ButtonLabel { get; init; }
public string? Validate()
{
if (!PushMessageValidator.IsSupportedVersion(Version)) return "Unsupported push payload version. Supported versions are 1 and 2.";
if (string.IsNullOrWhiteSpace(Token) || Token.Length > 4096 || Token.Any(char.IsWhiteSpace))
return "Enter a valid recipient FCM token.";
if (Version == 1) return null;
if (BindingId is null || BindingId == Guid.Empty) return "Enter the device's current binding ID for the button-format test.";
try
{
PushTemplateActionValidation.Validate(new PushTemplateActions
{
DefaultAction = ActionKey,
Buttons = [new PushTemplateButton { Action = ActionKey!, Label = ButtonLabel! }]
});
return null;
}
catch (EmailTemplateWriteException) { return "Enter a valid action key and a button label (1–100 characters)."; }
}
}
@@ -9,13 +9,17 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
{
public async Task<PushDeliveryOutcome> TestAsync(PushChannelTestSettings settings, bool send, CancellationToken ct)
{
if (send && !ValidToken(settings.RecipientToken))
return new PushDeliveryOutcome("Enter a valid recipient FCM token.");
var message = new PushChannelTestMessage
{
Token = settings.RecipientToken?.Trim() ?? "", Version = settings.Version,
BindingId = settings.BindingId, ActionKey = settings.ActionKey, ButtonLabel = settings.ButtonLabel
};
if (send && message.Validate() is { } error) return new PushDeliveryOutcome(error);
var project = settings.FirebaseProjectId?.Trim() ?? "";
var json = await ResolveCredentialsAsync(settings, project, ct);
if (string.IsNullOrWhiteSpace(json))
return new PushDeliveryOutcome("Provide credentials for this project. Saved credentials can only be used with the unchanged project.");
return await ExecuteAsync(json, project, settings.RecipientToken, send, ct);
return await ExecuteAsync(json, project, message, send, ct);
}
private async Task<string?> ResolveCredentialsAsync(PushChannelTestSettings settings, string project, CancellationToken ct)
@@ -26,12 +30,12 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
return saved?.FirebaseProjectId == project ? await channels.GetCredentialsAsync(id, ct) : null;
}
private async Task<PushDeliveryOutcome> ExecuteAsync(string json, string project, string? token, bool send, CancellationToken ct)
private async Task<PushDeliveryOutcome> ExecuteAsync(string json, string project, PushChannelTestMessage message, bool send, CancellationToken ct)
{
try
{
var credentials = FirebaseServiceAccountCredentials.Parse(json, project);
return send ? await firebase.SendTestAsync(token!.Trim(), credentials, ct)
return send ? await firebase.SendTestAsync(message, credentials, ct)
: await firebase.CheckAuthorizationAsync(credentials, ct);
}
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
@@ -44,6 +48,4 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
}
}
private static bool ValidToken(string? token) => !string.IsNullOrWhiteSpace(token)
&& token.Trim().Length <= 4096 && !token.Trim().Any(char.IsWhiteSpace);
}
@@ -6,4 +6,8 @@ public sealed class PushChannelTestSettings
public string? FirebaseProjectId { get; set; }
public string? CredentialsJson { get; set; }
public string? RecipientToken { get; set; }
public int Version { get; set; } = 1;
public Guid? BindingId { get; set; }
public string? ActionKey { get; set; }
public string? ButtonLabel { get; set; }
}
@@ -61,7 +61,7 @@ public sealed class PushDeliveryService(IPushDeliveryRepository deliveries, IPus
CorrelationContext = message.CorrelationContext with { ReplyTo = null },
Data = new NotificationResultData
{
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = "PushDiagnostic",
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = message.Data.Version == 1 ? "PushDiagnostic" : message.Data.TemplateKey!,
Timestamp = DateTimeOffset.UtcNow, ErrorMessage = outcome.Error,
PushRegistrationId = message.Data.RegistrationId, PushBindingId = message.Data.BindingId,
PushTokenHash = message.Data.TokenHash, InvalidPushRegistration = outcome.InvalidRegistration
@@ -8,11 +8,27 @@ public static class PushMessageValidator
{
if (message?.Data is not { } data || message.CorrelationContext is null) return false;
return Guid.TryParse(message.CorrelationContext.CorrelationId, out _) &&
data.Version == 1 && data.RegistrationId != Guid.Empty && data.BindingId != Guid.Empty &&
data.RegistrationId != Guid.Empty && data.BindingId != Guid.Empty &&
data.ServiceName is { Length: > 0 and <= 100 } && data.UserId != Guid.Empty &&
data.Token is { Length: > 0 and <= 2048 } && data.TokenHash is { Length: 64 } &&
data.Title is { Length: > 0 and <= 100 } && data.Body is { Length: > 0 and <= 500 } &&
data.Route == "/home" && data.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(15);
IsValidContent(data) && data.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(15);
}
public static bool IsSupportedVersion(int version) => version is 1 or 2;
public static bool IsValidContent(SendPushMessageData data) => data.Version switch
{
1 => data.Title is { Length: > 0 and <= 100 } && data.Body is { Length: > 0 and <= 500 } && data.Route == "/home",
2 => ValidTemplateData(data),
_ => false
};
private static bool ValidTemplateData(SendPushMessageData data) =>
data.TemplateKey is { Length: > 0 and <= 100 } &&
data.LanguageCode is { Length: > 0 and <= 10 } &&
data.TemplateData?.Bindings is not null && data.TemplateData.Actions is not null &&
data.TemplateData.Bindings.All(pair => !string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null) &&
data.TemplateData.Actions.All(pair => !string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null &&
(pair.Value.ObjectId is null || pair.Value.ObjectId is { Length: > 0 and <= 256 }));
}
@@ -0,0 +1,56 @@
using System.Globalization;
using System.Text.RegularExpressions;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Worker.Services.Push;
public static partial class PushTemplateRenderer
{
public static RenderedPushMessageData Render(PushTemplateDefinition template, SendPushMessageData message)
{
if (message.Version != 2 || !PushMessageValidator.IsValidContent(message))
throw new EmailTemplateWriteException("Invalid version 2 push template data.", false);
var data = message.TemplateData!;
var language = template.Languages.SingleOrDefault(l => l.LanguageCode == message.LanguageCode)
?? template.Languages.SingleOrDefault(l => l.LanguageCode == "en")
?? throw new EmailTemplateWriteException("Push template language and English fallback are missing.", false);
PushTemplateValidation.Validate(template);
ValidateRequiredBindings(template, data.Bindings);
var actions = PushTemplateActionValidation.Parse(language.ActionsJson);
return new RenderedPushMessageData(message,
RenderText(language.Title, data.Bindings, 100),
RenderText(language.Body, data.Bindings, 500),
actions.DefaultAction is null ? null : ResolveAction(actions.DefaultAction, null, data),
actions.Buttons.Select(b => ResolveAction(b.Action,
RenderText(b.Label, data.Bindings, 100), data)).ToList());
}
private static void ValidateRequiredBindings(PushTemplateDefinition template, IReadOnlyDictionary<string, string> bindings)
{
foreach (var variable in template.Variables.Where(v => v.Required))
if (!bindings.TryGetValue(variable.Name, out var value) || string.IsNullOrWhiteSpace(value))
throw new EmailTemplateWriteException("A required push binding is missing.", false);
}
private static RenderedPushAction ResolveAction(string key, string? label, PushTemplateData data)
{
if (!data.Actions.TryGetValue(key, out var metadata) || metadata is null)
throw new EmailTemplateWriteException("A configured push action is missing from message metadata.", false);
return new RenderedPushAction(key, metadata.ObjectId, label);
}
private static string RenderText(string template, IReadOnlyDictionary<string, string> bindings, int limit)
{
var value = Placeholder().Replace(template, match => bindings.GetValueOrDefault(match.Groups[1].Value) ?? "");
if (string.IsNullOrWhiteSpace(value))
throw new EmailTemplateWriteException("Rendered push text is empty.", false);
if (value.Length <= limit) return value;
var starts = StringInfo.ParseCombiningCharacters(value);
var end = starts.LastOrDefault(offset => offset <= limit - 1);
return value[..end] + "…";
}
[GeneratedRegex(@"\{\{([^{}]+)\}\}")]
private static partial Regex Placeholder();
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed record RenderedPushAction(string Action, string? ObjectId = null, string? Label = null);
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Contracts.Messages;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed record RenderedPushMessageData(
SendPushMessageData Delivery, string Title, string Body,
RenderedPushAction? DefaultAction, IReadOnlyList<RenderedPushAction> Buttons);
@@ -14,6 +14,11 @@ public sealed class SendPushConsumer(
protected override bool TryValidateMessage(SendPushMessage message, RabbitMqMessageContext context, out string? validationError)
{
if (message?.Data is { } data && !PushMessageValidator.IsSupportedVersion(data.Version))
{
validationError = "Unsupported push payload version. Supported versions are 1 and 2.";
return false;
}
bool valid = SendPushDeliveryValidator.IsValid(message, context);
validationError = valid ? null : "Invalid push envelope.";
return valid;
+2
View File
@@ -4,6 +4,8 @@ Production image references now use `registry.hrynco.com`, preserving the existi
## Documentation
- [Email and push template model](docs/channel-template-model.md)
- [Push channels and Android FCM](docs/android-push.md)
- [Email template definitions and service languages](docs/email-template-localizations.md)
- [Service and whole-template API for AI clients](docs/email-template-definition-api.md)
+25
View File
@@ -166,3 +166,28 @@ runtime validation remain separate pending work. TeamCity main triggers include
checks, image publication, Contracts publication and production deployment. The owner
explicitly authorized this automatic production consequence for the Notification Service
delivery on 2026-10-24. It does not authorize delivery of the remaining Invemory repositories.
## Template action increment — 2026-10-31 (historical initial implementation)
The new template worker emits FCM data version 2 with localized buttons and a
primary action, each carrying an opaque action key and objectId. This supersedes
the ready-made title/body/route send format above for current Notification Service
source. The owner explicitly chose no old-format compatibility branches. The
installed Invemory client and its producers are not updated in this increment;
version 2 action display/tap handling must be implemented before activating the new
producer. See [channel templates](channel-template-model.md) for the full contract.
### Superseding compatibility decision
The owner subsequently required compatibility within the same Firebase channel.
Requests with no version or version=1 again accept ready-made title/body and emit
the original version=1, route=/home data envelope. Only explicit version=2 renders
templates and action metadata; unknown versions fail. Section actions allow missing
objectId. Android's current version=1 validator remains unchanged and supported.
Channel test-send now selects legacy simple notification or version 2 with one
localized button. The latter requires a real installation binding and a
consumer-supported section action key; it uses the new data-envelope format and
does not claim old clients can show buttons. Both modes use the same saved or
unsaved Firebase credentials and never modify the channel. Capability registration
and installation-based producer routing are deferred to Invemory integration.
+266
View File
@@ -0,0 +1,266 @@
# Channel template model — IT-1627
## Current compatibility decision — 2026-10-31 (supersedes earlier checkpoints)
The owner subsequently required independently deliverable Notification Service
changes. Request `version` now selects the format: omitted or `1` accepts the
original title/body/route request and emits the original version=1 data payload with
route=/home, without template lookup or actions. Version `2` requires templateKey,
languageCode and templateData and emits localized buttons. Unsupported versions
are explicit validation errors, never a fallback. One Firebase channel serves both.
The earlier no-legacy decision below is historical and no longer applies.
An action metadata entry is still required for each selected action, but its
objectId is optional. Section-only actions use `{}`; no synthetic object IDs.
For Invemory, the agreed first button label is «Перейти в розділ», localized in the
template, and the key determines contacts/containers/basket navigation. No mutation
or direct object-opening behavior is added in this increment.
Push Channel test-send has a format selector. Legacy preserves its existing simple
Firebase notification test (token only), distinct from the production version=1
data-envelope flow. Version 2 emits the actual action data envelope with one button
and matching primary action, requiring the actual device binding ID, action key and
label. It never invents a binding or object ID. Unknown versions and missing binding
are rejected before provider calls. Test fields are not channel configuration;
token is cleared after send, and token/binding are cleared on modal close.
Version 2 requires a client that supports this payload. Current Invemory Android
only accepts version=1 and /home; producer capability routing and client updates
remain outside this Notification Service increment. Keeping version 1 as the
default permits independent service delivery without changing existing producers.
## Scope and sequence
The owner agreed on 2026-10-30 to implement the model first, then adapt the
Notification Service administration. The service page is called Templates;
the Template editor has Email and Push channel navigation, each containing language tabs.
A key may have Email only, Push only, or both. Presence never selects a delivery
channel automatically. Transport rendering and Invemory producers are later work.
The model and administration increments supply persistence and Email/Push editing,
while preserving existing email API behavior. The action increment adds push
rendering/forwarding; Invemory producers and native action handling are still pending.
## Owner decision: action metadata — 2026-10-31
The template-data object has two properties: binding values and structured action
metadata supplied by the producer. Actions belong to the individual template;
there is no service-level action registry or registration endpoint. Template authors
know the action keys. Notification Service validates structure without implementing
consumer-specific behavior. The receiving application interprets actions and checks
current access. Localized button labels belong to the template language version.
The final owner-approved metadata is only an objectId; the dictionary key identifies
the action. There is no action type field, arbitrary parameters, or translation-key
registry. Authoring actions requires knowledge of the consuming application's keys.
The owner explicitly rejected legacy push-format compatibility branches. This is
a new template format; existing Email behavior remains unchanged. Work proceeds
through contract/editor/model, rendering/forwarding, then client handling coordinated
with IT-1628. Saving or previewing buttons does not itself add native Android actions.
Read/unread state and automatic business mutations are not introduced.
For subsequent owner-authorized local dev migrations, do not create backups, per
the owner's explicit preference. This does not change production rollout rules.
## Storage details
- templates: shared identity, service ID, exact key and timestamps. The composite
service/key alternate key is unique. Service deletion is restricted.
- email_template_definitions: retained email channel identity, revision, variables,
and translations. Its service/key now references the shared template.
- push_template_definitions: independent channel identity, revision and variables;
unique service/key referencing the same shared template. No email is required.
- push_templates: language versions with plain-text title/body. Unique
definition/language; composite foreign keys ensure that the definition and
language belong to the same service. Content remains text because placeholders
are rendered before the existing final-payload size limits are checked.
Each channel owns its variable contract, shared across that channel's languages.
A missing channel has no definition row. An existing channel with no translations
has a definition row and an empty version collection. The frontend must distinguish
these states and must not create a channel merely by opening its tab.
## Compatibility and mutations
Existing email creation paths create or reuse the shared key under the existing
transaction advisory lock 1601. Existing IDs, keys, revisions, variables and
language content are unchanged by migration. Email APIs continue to expose only
email definitions and translations. Creating email for an existing push-only key
reuses that catalog entry.
Deleting an email definition preserves a matching push definition and its shared
key. If no push channel exists, that compatibility operation also removes the
now-unused catalog entry. Deleting an email translation retains its definition.
Service deletion checks the shared catalog, including push-only keys.
Service language usage counts include both channels. Ordinary removal rejects any
used language; explicitly confirmed deletion removes both channels' translations
atomically and retains their definitions. Service/catalog edits refresh both
channels' revisions, so editors cannot silently save against an outdated catalog.
## Migration and rollout
AddChannelTemplates creates the shared catalog from existing email definitions,
retaining their IDs as the initial shared template IDs, and adds push tables and
foreign keys. It does not touch the existing email content or transport payloads.
No owner or production database is migrated as part of source implementation.
Stop administration writes and the migrator's development seeding while upgrading;
apply the migration and run the matching Web/Migrator build before resuming writes.
The previous Web binary cannot create email keys against the new foreign key.
Existing email reads and sends remain compatible. Use the existing backup and
explicit migration procedure in email-template-localizations.md.
Downgrade preserves existing email rows but refuses to discard push definitions,
including empty ones. Export and explicitly remove push data before an intentional
rollback; otherwise restore the verified backup with its matching application.
## Verification
ChannelTemplateModelTests uses disposable PostgreSQL databases to cover migration
and rerun, independent channel contracts, email-only/push-only/combined keys,
adding email to a push-only key, email deletion preserving push, language deletion,
duplicate language rejection, cross-service language rejection, and rollback guards.
Existing migration/API/worker tests provide email regression coverage.
## Administration increment — 2026-10-31
After separate owner authorization on 2026-10-31, AddChannelTemplates was applied
to the local Development database at localhost:5433/notification_service using
update-development-database.bat. A custom-format backup was taken first with Web
stopped. Before/after data-only dumps confirmed unchanged services, service
languages, email definitions and translations. All eight email definitions have
shared catalog rows. The matching task Web build was restarted on localhost:5188;
production was not accessed.
The service table has one row per exact key and separate Email/Push columns. Each
channel shows its configured-language statuses or Not configured. Existing email
URLs and key filters remain supported. The editor has service/key context followed
by Email/Push navigation, then channel-specific variables and language tabs.
Channel navigation uses normal server-rendered links styled as tabs, with
aria-current identifying the selected channel. Opening a missing channel shows a
Create Email Template or Create Push Template action; GET requests never persist
anything. The new-template page defaults to an unsaved Email form and can switch
to Push before saving. A shared key is fixed when adding a channel to an existing
key. A channel may retain zero translations.
Push editing supplies Title and Body, independent variables, exact catalog-language
validation, revision checks and unknown/incomplete placeholder validation. Blank
Title and Body omit/remove that version; a partial version rejects the whole save.
Failed saves retain entered values, active language and original revision. Removing
Push has a separate antiforgery-protected confirmation and preserves Email; Email
confirmation explicitly states that Push is retained.
Both editors reuse the existing language keyboard behavior and sample-variable
preview script. Interpolation is single-pass: sample values containing placeholders
are not interpreted again. Push preview writes plain text; email HTML remains
encoded and sandboxed. A modal Keep editing / Discard changes choice guards links
when fields are dirty, including after validation errors. Language switching stays
inside the page and retains entered values. Saving affects only the selected channel.
The existing browser unload warning remains supplementary.
Validation on 2026-10-31: all 67 service/worker and 166 PostgreSQL integration
tests passed. Chrome against a temporary host and disposable database covered
creating Push first, adding Email under the same key, independent content and
variables, validation with retained input, language keyboard navigation, preview,
and keeping unsaved edits through the navigation dialog. Desktop 1440px and narrow
390/320px layouts were checked; the table scrolls locally without page overflow.
This was administration browser validation, not installed Android or end-to-end
push delivery validation. Owner databases were not migrated. Cognitive complexity
was structurally reviewed; numeric analyzer scores were not measured.
The table has a minimum readable width and scrolls within its wrapper at narrow
widths. The shared stylesheet is fingerprinted to avoid cached pre-change layouts.
No editor is added to the Invemory web or installed Android application.
## Push template actions and rendering — 2026-10-31
`SendPushMessageData` now requires `templateKey`, `languageCode` and `templateData`
instead of ready-made title/body/route. Delivery identity, expiry and registration
fields remain. `templateData` has exactly two model properties:
```json
{
"bindings": { "Name": "Alex" },
"actions": { "openNeed": { "objectId": "need-42" } }
}
```
Each language version stores `actions` as JSON, edited alongside title/body:
```json
{
"defaultAction": "openNeed",
"buttons": [{ "action": "openNeed", "label": "View need" }]
}
```
Labels belong to that language and may reference declared text bindings. The order
of buttons is their display order. This first editor supports up to three buttons,
unique action keys and nonempty labels up to 100 template characters. `{}` means
no actions. Clear title/body and reset actions to `{}` to remove a translation.
Missing action metadata fails the send; it never silently creates a dead button.
Only actions selected in the template are forwarded. IDs are opaque strings;
Notification Service neither constructs URLs nor interprets consumer actions.
The worker resolves the exact language then English, checks required bindings,
substitutes once, and truncates title/body/label to 100/500/100 UTF-16 code units
without splitting a text element. Missing optional bindings become empty text;
empty final content fails. User-supplied placeholder text is not reinterpreted.
FCM receives data payload version 2 with title/body and an `actions` JSON string
containing the resolved default action and buttons (action, objectId, label).
The serialized data dictionary is capped at 3500 UTF-8 bytes as a conservative
application budget; oversized payloads fail before contacting Firebase.
The RabbitMQ command type remains Notification.SendPush.v1; its payload contract
is replaced directly, with no old-title/body acceptance branch per owner decision.
AddPushTemplateActions adds a JSONB column with `{}` for existing versions. It was
applied to the local dev database without a backup, per owner instruction. Matching
Web is running on localhost:5188. This is not a production deployment. Do not send
this new contract from Invemory until its producers and installed client handling
are updated together. Native version 2/action handling remains separate work;
provider serialization tests do not demonstrate Android display or navigation.
Validation: 76 service/worker tests and 168 PostgreSQL integration tests passed.
New tests cover language fallback, single-pass substitution, Unicode truncation,
missing bindings/action IDs, malformed action definitions, forwarding and localized
editor persistence/errors. Rider found no errors in inspected production files;
remaining findings were style suggestions and the existing Firebase Token warning.
Cognitive complexity was structurally reviewed, not numerically measured. Reuse
review compared the existing email renderer and shared editor: push keeps a separate
single-pass plain-text renderer because email uses HTML encoding and repeated
replacement; channel editing, preview layout and revision handling are reused.
Browser validation against the updated local dev Web covered the Ukrainian action
label and primary-action preview. The 390px layout kept page width within the
viewport and the button remained visible and clickable as a preview-only control.
Server-side invalid JSON retention and save/reload were verified by integration
tests; an attempted browser submission lost its automation connection, so no
successful browser save is claimed for this increment. No QA push template was
persisted into the owner's catalog. No installed Android/real FCM send was tested.
## Compatibility increment verification — 2026-10-31
The complete solution passed 84 service/worker tests and 172 PostgreSQL integration
checks. Added coverage deserializes original requests with omitted/explicit version,
compares the seven-field legacy FCM envelope, proves legacy never queries templates,
rejects unsupported versions before provider calls, renders section actions without
objectId, and exercises both test-send formats through the antiforgery-protected
admin endpoint using a recording Firebase boundary. The unchanged authorization
and saved-credential behavior remain covered. No new migration is required.
Chrome against temporary host Web on 127.0.0.1:52658 verified default legacy mode,
version-2 fields, server rejection of missing binding before Firebase access, token
clearing, switching modes, and the scrollable 390px modal without horizontal page
overflow. The host was stopped afterward; the owner's Rider Web on 5188 was left
running. No real Firebase send was performed. No phone was connected through ADB,
and the existing Android source accepts only version 1; device display/tap behavior
for version 2 remains unverified and needs the separate client implementation.
Review reused existing version, sender, channel credential resolver, provider client,
and admin test endpoint rather than introducing another channel type. Compared the
legacy input validator and FCM builder against Git HEAD and the current Android
allowlist. Rider reported no errors in inspected changed production files; warnings
remain for existing Firebase Token deprecation, defensive JSON null checks and style.
Cognitive complexity was reviewed structurally against the limit of 10; numeric
scores were not measured. All changes remain uncommitted for owner review/testing.
+9
View File
@@ -111,3 +111,12 @@ The final preview/deletion browser pass used the same task-owned host and databa
Push channels also reference the service by ID and block service deletion while linked.
Reassign the push channel first; see [Push Channels](android-push.md).
## Channel model extension — IT-1627
Email definitions now reference the shared service/key catalog. Push definitions
have independent variables and translations. Language usage/deletion includes both
channels. See [channel template model](channel-template-model.md) for migration,
compatibility behavior, rollout restrictions and the implemented administration UI.
The catalog is now Templates with independent Email/Push columns. Each editor has
channel navigation and retains its own language tabs, variables and save action.
+23
View File
@@ -1,5 +1,14 @@
# Supported incoming message types
Current push version selection (owner decision 2026-10-31): the AMQP type remains
Notification.SendPush.v1. Its data.version defaults to 1 when omitted. Version 1
retains title/body, optional route defaulting to /home, and the original identity,
token and expiry validation; no template fields are required. Explicit version 2
requires templateKey/languageCode/templateData and renders buttons. Unknown versions
are rejected with an unsupported-version error. Version 1 emits version=1/route=/home
to FCM; version 2 emits version=2/actions. The historical no-legacy note below is
superseded by this decision. See channel-template-model.md for the current contract.
The Worker uses `SupportedMessageType` for its two incoming commands:
| AMQP MessageType (unchanged) | Public enum | Consumer queue |
@@ -24,3 +33,17 @@ correlation validation remains unchanged.
The Push Channels change removes the draft Firebase project/access-token fields from
SendPushMessageData. The enum and AMQP header stay unchanged; see
[push channels](android-push.md) for producer compatibility and rollout order.
## New templated push payload (IT-1627, owner decision 2026-10-31)
The push command now supplies templateKey, languageCode and templateData with
bindings and action metadata, replacing ready-made title/body/route. No legacy
payload acceptance branch is provided. Action keys are consumer-owned, with only
objectId in their metadata; labels are localized in the template. See
[channel templates](channel-template-model.md#push-template-actions-and-rendering--2026-10-31)
for the exact shape, validation and client integration still required.
Version-selection regression coverage: omitted/explicit version 1 legacy input and
its original FCM fields, explicit version 2 section buttons, unknown-version errors,
and both channel test modes passed. See channel-template-model.md for runtime and
review evidence and the remaining installed-client validation gap.