Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1351279b36 | |||
| aba4173827 |
@@ -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; }
|
||||
|
||||
+11
@@ -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();
|
||||
}
|
||||
+3
@@ -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 =>
|
||||
|
||||
+31
@@ -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; }
|
||||
}
|
||||
Generated
+674
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+679
@@ -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;
|
||||
|
||||
+2
-1
@@ -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;
|
||||
}
|
||||
|
||||
+22
@@ -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;
|
||||
}
|
||||
+38
@@ -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)]);
|
||||
}
|
||||
}
|
||||
+1
@@ -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; } = [];
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
Production image references now use `registry.hrynco.com`, preserving the existing image-tag variables. Contracts package repository metadata uses `gitea.hrynco.com`. These are source configuration changes; production containers and live CI configuration were not restarted or updated by this migration.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user