Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e268b8ec3 |
@@ -1,6 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.Contracts.Messages;
|
|
||||||
|
|
||||||
public sealed record PushActionMetadata
|
|
||||||
{
|
|
||||||
public string? ObjectId { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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,12 +6,9 @@ public sealed record SendPushMessageData
|
|||||||
public required Guid BindingId { get; init; }
|
public required Guid BindingId { get; init; }
|
||||||
public required Guid UserId { get; init; }
|
public required Guid UserId { get; init; }
|
||||||
public required string ServiceName { get; init; }
|
public required string ServiceName { get; init; }
|
||||||
public string? Title { get; init; }
|
public required string Title { get; init; }
|
||||||
public string? Body { get; init; }
|
public required string Body { get; init; }
|
||||||
public string Route { get; init; } = "/home";
|
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 int Version { get; init; } = 1;
|
||||||
public required DateTimeOffset ExpiresAt { get; init; }
|
public required DateTimeOffset ExpiresAt { get; init; }
|
||||||
public string? Token { get; init; }
|
public string? Token { get; init; }
|
||||||
|
|||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
|
||||||
|
|
||||||
public interface IPushTemplateDefinitionRepository
|
|
||||||
{
|
|
||||||
Task<PushTemplateDefinition?> UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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; } = [];
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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; } = [];
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
|
|
||||||
public sealed class PushTemplateDefinitionPatch
|
|
||||||
{
|
|
||||||
public Guid ServiceId { get; init; }
|
|
||||||
public required string Key { get; init; }
|
|
||||||
public Guid? Revision { get; init; }
|
|
||||||
public required IReadOnlyList<EmailTemplateVariable> Variables { get; init; }
|
|
||||||
public required IReadOnlyList<PushTemplateTranslation> Translations { get; init; }
|
|
||||||
public IReadOnlyList<string> RemoveLanguageCodes { get; init; } = [];
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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,9 +17,6 @@ internal sealed class EmailTemplateDefinitionEntityConfiguration : IEntityTypeCo
|
|||||||
builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique();
|
builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique();
|
||||||
builder.HasAlternateKey(x => new { x.Id, x.ServiceId });
|
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.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.Created).HasColumnName("created");
|
||||||
builder.Property(x => x.Updated).HasColumnName("updated");
|
builder.Property(x => x.Updated).HasColumnName("updated");
|
||||||
builder.OwnsMany(x => x.Variables, v =>
|
builder.OwnsMany(x => x.Variables, v =>
|
||||||
|
|||||||
-31
@@ -1,31 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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,7 +4,6 @@ namespace HrynCo.NotificationService.DAL.EF.Entities;
|
|||||||
|
|
||||||
internal sealed class EmailTemplateDefinitionEntity : Entity
|
internal sealed class EmailTemplateDefinitionEntity : Entity
|
||||||
{
|
{
|
||||||
public TemplateEntity Template { get; set; } = null!;
|
|
||||||
public Guid ServiceId { get; set; }
|
public Guid ServiceId { get; set; }
|
||||||
public ServiceEntity Service { get; set; } = null!;
|
public ServiceEntity Service { get; set; } = null!;
|
||||||
public required string Key { get; set; }
|
public required string Key { get; set; }
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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; } = [];
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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; } = "{}";
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.DAL.EF.Entities;
|
|
||||||
|
|
||||||
internal sealed class PushTemplateVariableData
|
|
||||||
{
|
|
||||||
public required string Name { get; set; }
|
|
||||||
public bool Required { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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
@@ -1,674 +0,0 @@
|
|||||||
// <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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
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
@@ -1,679 +0,0 @@
|
|||||||
// <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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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,98 +272,6 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
|
|||||||
b.ToTable("PushDeliveries", (string)null);
|
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 =>
|
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -431,36 +339,6 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
|
|||||||
b.ToTable("service_languages", (string)null);
|
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 =>
|
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||||
@@ -489,13 +367,6 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Restrict)
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
.IsRequired();
|
.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 =>
|
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
|
||||||
{
|
{
|
||||||
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
|
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
|
||||||
@@ -524,8 +395,6 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
|
|||||||
|
|
||||||
b.Navigation("Service");
|
b.Navigation("Service");
|
||||||
|
|
||||||
b.Navigation("Template");
|
|
||||||
|
|
||||||
b.Navigation("Variables");
|
b.Navigation("Variables");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,67 +430,6 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
|
|||||||
b.Navigation("Service");
|
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 =>
|
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||||
@@ -633,17 +441,6 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
|
|||||||
b.Navigation("Service");
|
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 =>
|
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("UsageRecords");
|
b.Navigation("UsageRecords");
|
||||||
@@ -654,22 +451,10 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
|
|||||||
b.Navigation("Languages");
|
b.Navigation("Languages");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Languages");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Languages");
|
b.Navigation("Languages");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Email");
|
|
||||||
|
|
||||||
b.Navigation("Push");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ 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<ServiceLanguageEntity> ServiceLanguages => Set<ServiceLanguageEntity>();
|
||||||
internal DbSet<PushChannelEntity> PushChannels => Set<PushChannelEntity>();
|
internal DbSet<PushChannelEntity> PushChannels => Set<PushChannelEntity>();
|
||||||
internal DbSet<ServiceEntity> Services => Set<ServiceEntity>();
|
internal DbSet<ServiceEntity> Services => Set<ServiceEntity>();
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
|
|||||||
if (entity is not null)
|
if (entity is not null)
|
||||||
throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating.");
|
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 = 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);
|
db.TemplateDefinitions.Add(entity);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
@@ -126,7 +125,6 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
|
|||||||
if (definition.Revision != revision)
|
if (definition.Revision != revision)
|
||||||
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
|
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
|
||||||
db.TemplateDefinitions.Remove(definition);
|
db.TemplateDefinitions.Remove(definition);
|
||||||
await RemoveEmptyTemplateAsync(serviceId, key, ct);
|
|
||||||
removed = true;
|
removed = true;
|
||||||
}, ct);
|
}, ct);
|
||||||
return removed;
|
return removed;
|
||||||
@@ -263,31 +261,12 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
|
|||||||
?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false);
|
?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false);
|
||||||
var entity = new EmailTemplateDefinitionEntity
|
var entity = new EmailTemplateDefinitionEntity
|
||||||
{
|
{
|
||||||
Template = await GetOrCreateTemplateAsync(service, key, ct),
|
|
||||||
Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow
|
Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow
|
||||||
};
|
};
|
||||||
db.TemplateDefinitions.Add(entity);
|
db.TemplateDefinitions.Add(entity);
|
||||||
return 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)
|
private static void ValidateLegacy(EmailTemplate template)
|
||||||
{
|
{
|
||||||
EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key);
|
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)
|
private async Task EnsureServiceIsEmptyAsync(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (await db.TemplateCatalog.AnyAsync(d => d.ServiceId == id, ct))
|
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct))
|
||||||
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
|
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))
|
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.");
|
throw new EmailTemplateWriteException("Reassign or remove every notification channel before deleting this service.");
|
||||||
@@ -76,11 +76,9 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
|
|||||||
var language = service.Languages.SingleOrDefault(l => l.Id == languageId)
|
var language = service.Languages.SingleOrDefault(l => l.Id == languageId)
|
||||||
?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service.");
|
?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service.");
|
||||||
var versions = await db.Templates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
|
var versions = await db.Templates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
|
||||||
var pushVersions = await db.PushTemplates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
|
if (service.Revision != expectedRevision || versions.Count != expectedVersionCount)
|
||||||
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.");
|
throw new EmailTemplateWriteException("The service or affected version count changed. Review the current count and confirm again.");
|
||||||
db.Templates.RemoveRange(versions);
|
db.Templates.RemoveRange(versions);
|
||||||
db.PushTemplates.RemoveRange(pushVersions);
|
|
||||||
// Save dependent removals first because language deletion is restricted by its FK.
|
// Save dependent removals first because language deletion is restricted by its FK.
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
db.ServiceLanguages.Remove(language);
|
db.ServiceLanguages.Remove(language);
|
||||||
@@ -114,8 +112,7 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
|
|||||||
var removed = service.Languages.Where(l => !retained.Contains(l.Id)).ToList();
|
var removed = service.Languages.Where(l => !retained.Contains(l.Id)).ToList();
|
||||||
foreach (var language in removed)
|
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)
|
if (count > 0)
|
||||||
throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation.");
|
throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation.");
|
||||||
}
|
}
|
||||||
@@ -171,17 +168,10 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
|
|||||||
definition.Revision = Guid.NewGuid();
|
definition.Revision = Guid.NewGuid();
|
||||||
definition.Updated = DateTimeOffset.UtcNow;
|
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()
|
private Task<Dictionary<Guid, int>> VersionCountsAsync(CancellationToken ct) => db.Templates.AsNoTracking()
|
||||||
.Select(t => t.ServiceLanguageId).Concat(db.PushTemplates.Select(t => t.ServiceLanguageId))
|
.GroupBy(t => t.ServiceLanguageId).Select(g => new { Id = g.Key, Count = g.Count() })
|
||||||
.GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() })
|
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Count, ct);
|
.ToDictionaryAsync(g => g.Id, g => g.Count, ct);
|
||||||
|
|
||||||
private static void Validate(NotificationServiceInfo service)
|
private static void Validate(NotificationServiceInfo service)
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
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(() => SaveCoreAsync(definition, isNew, ct), ct);
|
|
||||||
|
|
||||||
private async Task SaveCoreAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<PushTemplateDefinition?> UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
PushTemplateDefinition? saved = null;
|
|
||||||
await mutations.ExecuteAsync(async () =>
|
|
||||||
{
|
|
||||||
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == patch.ServiceId, ct);
|
|
||||||
if (service is null) return;
|
|
||||||
ValidatePatch(patch, service);
|
|
||||||
var current = await GetAsync(patch.ServiceId, patch.Key, ct);
|
|
||||||
var supplied = patch.Translations.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
|
||||||
var removed = patch.RemoveLanguageCodes.ToHashSet(StringComparer.Ordinal);
|
|
||||||
saved = new PushTemplateDefinition
|
|
||||||
{
|
|
||||||
Id = current?.Id ?? Guid.Empty, ServiceId = patch.ServiceId, Key = patch.Key,
|
|
||||||
Revision = patch.Revision ?? Guid.Empty, Variables = patch.Variables,
|
|
||||||
Languages = (current?.Languages ?? []).Where(l => !supplied.Contains(l.LanguageCode) && !removed.Contains(l.LanguageCode))
|
|
||||||
.Concat(patch.Translations).OrderBy(l => l.LanguageCode, StringComparer.Ordinal).ToList()
|
|
||||||
};
|
|
||||||
await SaveCoreAsync(saved, patch.Revision is null, ct);
|
|
||||||
}, ct);
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidatePatch(PushTemplateDefinitionPatch patch, ServiceEntity service)
|
|
||||||
{
|
|
||||||
PushTemplateValidation.Validate(new PushTemplateDefinition
|
|
||||||
{
|
|
||||||
ServiceId = patch.ServiceId, Key = patch.Key, Variables = patch.Variables, Languages = patch.Translations
|
|
||||||
});
|
|
||||||
if (patch.Revision == Guid.Empty)
|
|
||||||
throw new EmailTemplateWriteException("Revision must be null for creation or a nonempty revision from the last read for update.", false);
|
|
||||||
var removed = patch.RemoveLanguageCodes;
|
|
||||||
if (removed.Any(string.IsNullOrWhiteSpace) || removed.Distinct(StringComparer.Ordinal).Count() != removed.Count)
|
|
||||||
throw new EmailTemplateWriteException("removeLanguageCodes must contain unique nonblank language codes.", false);
|
|
||||||
var supplied = patch.Translations.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
|
||||||
if (removed.Any(supplied.Contains))
|
|
||||||
throw new EmailTemplateWriteException("A language cannot be supplied and removed in the same request.", false);
|
|
||||||
var supported = service.Languages.Select(l => l.Code).ToHashSet(StringComparer.Ordinal);
|
|
||||||
if (supplied.Concat(removed).Any(code => !supported.Contains(code)))
|
|
||||||
throw new EmailTemplateWriteException("Every supplied or removed language must exactly match a code in this service's catalog.", false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private 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);
|
|
||||||
db.PushTemplates.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)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || revision == Guid.Empty)
|
|
||||||
throw new EmailTemplateWriteException("An exact key (at most 100 characters) and a nonempty revision are required.", false);
|
|
||||||
var removed = false;
|
|
||||||
await mutations.ExecuteAsync(async () => removed = await DeleteCoreAsync(serviceId, key, revision, ct), ct);
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<bool> DeleteCoreAsync(Guid serviceId, string key, Guid revision, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
|
|
||||||
if (entity is null) return false;
|
|
||||||
if (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));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,7 +20,6 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.PushChannels.IPushChannelRepository, PushChannelRepository>();
|
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.PushChannels.IPushChannelRepository, PushChannelRepository>();
|
||||||
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.Push.IPushDeliveryRepository, PushDeliveryRepository>();
|
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.Push.IPushDeliveryRepository, PushDeliveryRepository>();
|
||||||
services.AddScoped<INotificationServiceRepository, NotificationServiceRepository>();
|
services.AddScoped<INotificationServiceRepository, NotificationServiceRepository>();
|
||||||
services.AddScoped<IPushTemplateDefinitionRepository, PushTemplateDefinitionRepository>();
|
|
||||||
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
|
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
|
||||||
services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>();
|
services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>();
|
||||||
services.AddScoped<IEmailChannelRepository, EmailChannelRepository>();
|
services.AddScoped<IEmailChannelRepository, EmailChannelRepository>();
|
||||||
|
|||||||
@@ -12,16 +12,12 @@ namespace HrynCo.NotificationService.Services.Tests.Push;
|
|||||||
|
|
||||||
public sealed class FcmPushSenderTests
|
public sealed class FcmPushSenderTests
|
||||||
{
|
{
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData(1)]
|
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials()
|
||||||
[InlineData(2)]
|
|
||||||
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials(int version)
|
|
||||||
{
|
{
|
||||||
var services = Substitute.For<INotificationServiceRepository>();
|
var services = Substitute.For<INotificationServiceRepository>();
|
||||||
var channels = Substitute.For<IPushChannelRepository>();
|
var channels = Substitute.For<IPushChannelRepository>();
|
||||||
var client = Substitute.For<IFirebasePushClient>();
|
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 service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory-Renamed" };
|
||||||
var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true };
|
var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true };
|
||||||
services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
|
services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
|
||||||
@@ -33,16 +29,12 @@ public sealed class FcmPushSenderTests
|
|||||||
private_key = rsa.ExportPkcs8PrivateKeyPem(), token_uri = "https://untrusted.invalid/token"
|
private_key = rsa.ExportPkcs8PrivateKeyPem(), token_uri = "https://untrusted.invalid/token"
|
||||||
});
|
});
|
||||||
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns(json);
|
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns(json);
|
||||||
client.SendAsync(Arg.Any<Guid>(), Arg.Any<RenderedPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
client.SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new PushDeliveryOutcome(null));
|
.Returns(new PushDeliveryOutcome(null));
|
||||||
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).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
|
||||||
var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), data, CancellationToken.None);
|
|
||||||
Assert.Null(result.Error);
|
Assert.Null(result.Error);
|
||||||
await client.Received(1).SendAsync(Arg.Any<Guid>(), Arg.Any<RenderedPushMessageData>(),
|
await client.Received(1).SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(),
|
||||||
Arg.Is<FirebaseServiceAccountCredentials>(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any<CancellationToken>());
|
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]
|
[Theory]
|
||||||
@@ -54,8 +46,6 @@ public sealed class FcmPushSenderTests
|
|||||||
var services = Substitute.For<INotificationServiceRepository>();
|
var services = Substitute.For<INotificationServiceRepository>();
|
||||||
var channels = Substitute.For<IPushChannelRepository>();
|
var channels = Substitute.For<IPushChannelRepository>();
|
||||||
var client = Substitute.For<IFirebasePushClient>();
|
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" };
|
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" };
|
||||||
if (exists) services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
|
if (exists) services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
|
||||||
if (active)
|
if (active)
|
||||||
@@ -64,7 +54,7 @@ public sealed class FcmPushSenderTests
|
|||||||
channels.GetActiveForServiceAsync(service.Id, Arg.Any<CancellationToken>()).Returns(channel);
|
channels.GetActiveForServiceAsync(service.Id, Arg.Any<CancellationToken>()).Returns(channel);
|
||||||
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns("secret-must-not-leak");
|
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns("secret-must-not-leak");
|
||||||
}
|
}
|
||||||
var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
|
var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
|
||||||
Assert.NotNull(result.Error);
|
Assert.NotNull(result.Error);
|
||||||
Assert.DoesNotContain("secret-must-not-leak", result.Error);
|
Assert.DoesNotContain("secret-must-not-leak", result.Error);
|
||||||
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
|
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
|
||||||
@@ -82,7 +72,7 @@ public sealed class FcmPushSenderTests
|
|||||||
private static SendPushMessageData Data(string serviceName) => new()
|
private static SendPushMessageData Data(string serviceName) => new()
|
||||||
{
|
{
|
||||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
||||||
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",
|
ServiceName = serviceName, Title = "Test", Body = "Test", Token = "test-device-token",
|
||||||
TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10)
|
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
|
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
|
||||||
{ ChannelId = id, FirebaseProjectId = "test-project", RecipientToken = send ? "test-token" : null }, send, default);
|
{ ChannelId = id, FirebaseProjectId = "test-project", RecipientToken = send ? "test-token" : null }, send, default);
|
||||||
Assert.Null(outcome.Error);
|
Assert.Null(outcome.Error);
|
||||||
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 ? 1 : 0).SendTestAsync("test-token", Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
|
||||||
await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(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);
|
await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default);
|
||||||
}
|
}
|
||||||
@@ -70,7 +70,7 @@ public sealed class PushChannelTestServiceTests
|
|||||||
{
|
{
|
||||||
var channels = Substitute.For<IPushChannelRepository>();
|
var channels = Substitute.For<IPushChannelRepository>();
|
||||||
var firebase = Client();
|
var firebase = Client();
|
||||||
firebase.SendTestAsync(Arg.Any<PushChannelTestMessage>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(Task.FromException<PushDeliveryOutcome>(new PushRetryException(TimeSpan.FromSeconds(30))));
|
.Returns(Task.FromException<PushDeliveryOutcome>(new PushRetryException(TimeSpan.FromSeconds(30))));
|
||||||
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
|
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
|
||||||
{ FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default);
|
{ FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default);
|
||||||
@@ -82,7 +82,7 @@ public sealed class PushChannelTestServiceTests
|
|||||||
var firebase = Substitute.For<IFirebasePushClient>();
|
var firebase = Substitute.For<IFirebasePushClient>();
|
||||||
firebase.CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
firebase.CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new PushDeliveryOutcome(null));
|
.Returns(new PushDeliveryOutcome(null));
|
||||||
firebase.SendTestAsync(Arg.Any<PushChannelTestMessage>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(new PushDeliveryOutcome(null));
|
.Returns(new PushDeliveryOutcome(null));
|
||||||
return firebase;
|
return firebase;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public sealed class PushDeliveryTests
|
|||||||
Data = new SendPushMessageData
|
Data = new SendPushMessageData
|
||||||
{
|
{
|
||||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
||||||
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)
|
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Title = "Invemory", Body = "Test", TokenHash = new string('a', 64)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
await service.CompleteFailureAsync(id, message, default);
|
await service.CompleteFailureAsync(id, message, default);
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
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() } }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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
|
Data = new SendPushMessageData
|
||||||
{
|
{
|
||||||
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
|
||||||
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",
|
ServiceName = "Invemory", Title = "Test", Body = "Test", Token = "test-device-token",
|
||||||
TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10)
|
TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using MediatR;
|
|
||||||
using Unit = HrynCo.NotificationService.Services.Core.Unit;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
public sealed record DeletePushTemplateDefinitionCommand(Guid ServiceId, string Key, Guid Revision) : IRequest<ServiceResult<Unit>>;
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using HrynCo.NotificationService.Services.Logging;
|
|
||||||
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
internal sealed class DeletePushTemplateDefinitionHandler(
|
|
||||||
IContextualSerilogLogger<DeletePushTemplateDefinitionCommand> logger, IPushTemplateDefinitionRepository repository)
|
|
||||||
: RequestHandler<DeletePushTemplateDefinitionCommand, ServiceResult<Unit>>(logger)
|
|
||||||
{
|
|
||||||
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeletePushTemplateDefinitionCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var removed = await repository.DeleteAsync(request.ServiceId, request.Key, request.Revision, cancellationToken);
|
|
||||||
return removed ? Success(Unit.Value) : Failure<Unit>("Template definition not found.", ServiceErrorCode.NotFound);
|
|
||||||
}
|
|
||||||
catch (EmailTemplateWriteException error)
|
|
||||||
{
|
|
||||||
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
|
||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using HrynCo.NotificationService.Services.Logging;
|
|
||||||
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
internal sealed class GetPushTemplateDefinitionHandler(
|
|
||||||
IContextualSerilogLogger<GetPushTemplateDefinitionQuery> logger, IPushTemplateDefinitionRepository definitions)
|
|
||||||
: RequestHandler<GetPushTemplateDefinitionQuery, ServiceResult<PushTemplateDefinitionDto>>(logger)
|
|
||||||
{
|
|
||||||
protected override async Task<ServiceResult<PushTemplateDefinitionDto>> DoOnHandle(GetPushTemplateDefinitionQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var definition = await definitions.GetAsync(request.ServiceId, request.Key, cancellationToken);
|
|
||||||
return definition is null
|
|
||||||
? Failure<PushTemplateDefinitionDto>("Template definition not found.", ServiceErrorCode.NotFound)
|
|
||||||
: Success(PushTemplateDefinitionMapping.Map(definition));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
public sealed record GetPushTemplateDefinitionQuery(Guid ServiceId, string Key) : IRequest<ServiceResult<PushTemplateDefinitionDto>>;
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
|
||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using HrynCo.NotificationService.Services.Logging;
|
|
||||||
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
internal sealed class GetServicePushTemplateDefinitionsHandler(
|
|
||||||
IContextualSerilogLogger<GetServicePushTemplateDefinitionsQuery> logger,
|
|
||||||
INotificationServiceRepository services, IPushTemplateDefinitionRepository definitions)
|
|
||||||
: RequestHandler<GetServicePushTemplateDefinitionsQuery, ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>>(logger)
|
|
||||||
{
|
|
||||||
protected override async Task<ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>> DoOnHandle(GetServicePushTemplateDefinitionsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (await services.GetAsync(request.ServiceId, cancellationToken) is null)
|
|
||||||
return Failure<IReadOnlyList<PushTemplateDefinitionDto>>("Service not found.", ServiceErrorCode.NotFound);
|
|
||||||
var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken);
|
|
||||||
return Success<IReadOnlyList<PushTemplateDefinitionDto>>(all.Select(PushTemplateDefinitionMapping.Map).ToList());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
public sealed record GetServicePushTemplateDefinitionsQuery(Guid ServiceId) : IRequest<ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>>;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
public sealed record PushTemplateDefinitionDto(Guid Id, Guid ServiceId, string Key, Guid Revision,
|
|
||||||
IReadOnlyList<TemplateVariableDto> Variables, IReadOnlyList<PushTemplateTranslationDto> Translations);
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
internal static class PushTemplateDefinitionMapping
|
|
||||||
{
|
|
||||||
public static PushTemplateDefinitionDto Map(PushTemplateDefinition model) => new(model.Id, model.ServiceId,
|
|
||||||
model.Key, model.Revision, model.Variables.Select(v => new TemplateVariableDto(v.Name, v.Required)).ToList(),
|
|
||||||
model.Languages.Select(l => new PushTemplateTranslationDto(l.LanguageCode, l.Title, l.Body,
|
|
||||||
PushTemplateActionValidation.Parse(l.ActionsJson))).ToList());
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
public sealed record PushTemplateTranslationDto(string LanguageCode, string Title, string Body, PushTemplateActions Actions);
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
public sealed record UpsertPushTemplateDefinitionCommand(PushTemplateDefinitionPatch Patch) : IRequest<ServiceResult<PushTemplateDefinitionDto>>;
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using HrynCo.NotificationService.Services.Logging;
|
|
||||||
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
|
|
||||||
internal sealed class UpsertPushTemplateDefinitionHandler(
|
|
||||||
IContextualSerilogLogger<UpsertPushTemplateDefinitionCommand> logger, IPushTemplateDefinitionRepository definitions)
|
|
||||||
: RequestHandler<UpsertPushTemplateDefinitionCommand, ServiceResult<PushTemplateDefinitionDto>>(logger)
|
|
||||||
{
|
|
||||||
protected override async Task<ServiceResult<PushTemplateDefinitionDto>> DoOnHandle(UpsertPushTemplateDefinitionCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken);
|
|
||||||
return definition is null
|
|
||||||
? Failure<PushTemplateDefinitionDto>("Service not found.", ServiceErrorCode.NotFound)
|
|
||||||
: Success(PushTemplateDefinitionMapping.Map(definition));
|
|
||||||
}
|
|
||||||
catch (EmailTemplateWriteException error)
|
|
||||||
{
|
|
||||||
return Failure<PushTemplateDefinitionDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -102,7 +102,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
|
|||||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||||
var root = $"/admin/services/{context.ServiceId}";
|
var root = $"/admin/services/{context.ServiceId}";
|
||||||
var templates = await context.Client.GetStringAsync(root);
|
var templates = await context.Client.GetStringAsync(root);
|
||||||
Assert.Contains("No templates for this service yet.", templates);
|
Assert.Contains("No email templates for this service yet.", templates);
|
||||||
Assert.Contains("Create First Template", templates);
|
Assert.Contains("Create First Template", templates);
|
||||||
Assert.Contains(root + "/templates/create", templates);
|
Assert.Contains(root + "/templates/create", templates);
|
||||||
Assert.Contains("template-key-filter", 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("Could not load templates", html);
|
||||||
Assert.Contains("Templates are unavailable", html);
|
Assert.Contains("Templates are unavailable", html);
|
||||||
Assert.DoesNotContain("Create First Template", html);
|
Assert.DoesNotContain("Create First Template", html);
|
||||||
Assert.DoesNotContain("No templates for this service yet.", html);
|
Assert.DoesNotContain("No email templates for this service yet.", html);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AssertConfirmationReset(string 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"));
|
string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml"));
|
||||||
Assert.Contains("asp-route-serviceId=\"@serviceId\"", view);
|
Assert.Contains("asp-route-serviceId=\"@serviceId\"", view);
|
||||||
Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view);
|
Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view);
|
||||||
Assert.Contains("new { serviceId, definitionId = email.Id, keyFilter }", view);
|
Assert.Contains("new { serviceId, definitionId = template.Id, keyFilter }", view);
|
||||||
Assert.DoesNotContain("/admin/templates", view);
|
Assert.DoesNotContain("/admin/templates", view);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
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: "/admin/services/create", Title: "Create Service", Form: "serviceForm"),
|
||||||
(Url: root + "/edit", Title: "Edit Service", Form: "serviceForm"),
|
(Url: root + "/edit", Title: "Edit Service", Form: "serviceForm"),
|
||||||
(Url: root + "/templates/create", Title: "Create Template", Form: "templateForm"),
|
(Url: root + "/templates/create", Title: "Create Email Template", Form: "templateForm"),
|
||||||
(Url: root + "/templates/" + definition.Id, Title: "Template: Layout", Form: "templateForm")
|
(Url: root + "/templates/" + definition.Id, Title: "Edit Email Template", Form: "templateForm")
|
||||||
};
|
};
|
||||||
foreach (var editor in editors)
|
foreach (var editor in editors)
|
||||||
AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form);
|
AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form);
|
||||||
@@ -32,7 +32,7 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
|
|||||||
fields["VariablesJson"] = "{invalid";
|
fields["VariablesJson"] = "{invalid";
|
||||||
var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
|
var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
|
||||||
Assert.Contains("Invalid variables JSON", errorHtml);
|
Assert.Contains("Invalid variables JSON", errorHtml);
|
||||||
AssertEditorLayout(errorHtml, "Create Template", "templateForm");
|
AssertEditorLayout(errorHtml, "Create Email Template", "templateForm");
|
||||||
Assert.Contains("<dl class=\"template-service-context\">", errorHtml);
|
Assert.Contains("<dl class=\"template-service-context\">", errorHtml);
|
||||||
Assert.Contains("<dt>Service</dt>", errorHtml);
|
Assert.Contains("<dt>Service</dt>", errorHtml);
|
||||||
Assert.Contains("<dd>TestService</dd>", errorHtml);
|
Assert.Contains("<dd>TestService</dd>", errorHtml);
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Net.Http.Json;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Nodes;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
|
||||||
|
|
||||||
public sealed class PushTemplateDefinitionApiTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
|
||||||
{
|
|
||||||
private static string Url(DefinitionApiTestContext c, string suffix = "", string key = "Welcome") =>
|
|
||||||
c.ServiceRoot + "/push-template-definitions" + suffix + "?key=" + Uri.EscapeDataString(key);
|
|
||||||
|
|
||||||
private static JsonObject Body(Guid? revision, params string[] languages) => new()
|
|
||||||
{
|
|
||||||
["revision"] = revision?.ToString(),
|
|
||||||
["variables"] = new JsonArray(new JsonObject { ["name"] = "Name", ["required"] = true }),
|
|
||||||
["translations"] = new JsonArray(languages.Select(code => (JsonNode)new JsonObject
|
|
||||||
{
|
|
||||||
["languageCode"] = code, ["title"] = "Title " + code, ["body"] = "Hello {{Name}}",
|
|
||||||
["actions"] = new JsonObject { ["defaultAction"] = "contacts", ["buttons"] = new JsonArray(
|
|
||||||
new JsonObject { ["action"] = "contacts", ["label"] = "Go " + code }) }
|
|
||||||
}).ToArray())
|
|
||||||
};
|
|
||||||
|
|
||||||
private static async Task<JsonElement> Read(DefinitionApiTestContext c) =>
|
|
||||||
(await c.Client.GetFromJsonAsync<JsonElement>(Url(c))).GetProperty("data");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task CreatePatchReadAndDeletePreserveEmailAndOmittedLanguages()
|
|
||||||
{
|
|
||||||
await using var c = await DefinitionApiTestContext.CreateAsync(fixture);
|
|
||||||
await c.UpsertAsync(DefinitionApiTestContext.Body(null, "en"));
|
|
||||||
var email = await c.ReadAsync();
|
|
||||||
var create = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en", "uk"));
|
|
||||||
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
|
||||||
Assert.NotNull(create.Headers.Location);
|
|
||||||
var original = await Read(c);
|
|
||||||
var revision = original.GetProperty("revision").GetGuid();
|
|
||||||
var update = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(revision, "de"));
|
|
||||||
Assert.Equal(HttpStatusCode.OK, update.StatusCode);
|
|
||||||
var saved = await Read(c);
|
|
||||||
Assert.Equal(3, saved.GetProperty("translations").GetArrayLength());
|
|
||||||
Assert.Equal(original.GetProperty("translations")[1].GetRawText(), saved.GetProperty("translations")[2].GetRawText());
|
|
||||||
Assert.Equal("contacts", saved.GetProperty("translations")[0].GetProperty("actions").GetProperty("defaultAction").GetString());
|
|
||||||
var all = await c.Client.GetFromJsonAsync<JsonElement>(c.ServiceRoot + "/push-template-definitions/all");
|
|
||||||
Assert.Single(all.GetProperty("data").EnumerateArray());
|
|
||||||
Assert.Equal(HttpStatusCode.Conflict, (await c.Client.DeleteAsync(Url(c) + "&revision=" + revision)).StatusCode);
|
|
||||||
var remove = Body(saved.GetProperty("revision").GetGuid());
|
|
||||||
remove["removeLanguageCodes"] = new JsonArray("de", "en", "uk");
|
|
||||||
Assert.Equal(HttpStatusCode.OK, (await c.Client.PostAsJsonAsync(Url(c, "/upsert"), remove)).StatusCode);
|
|
||||||
saved = await Read(c);
|
|
||||||
Assert.Empty(saved.GetProperty("translations").EnumerateArray());
|
|
||||||
Assert.Equal(HttpStatusCode.OK, (await c.Client.DeleteAsync(Url(c) + "&revision=" + saved.GetProperty("revision").GetGuid())).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await c.Client.GetAsync(Url(c))).StatusCode);
|
|
||||||
Assert.Equal(email.GetRawText(), (await c.ReadAsync()).GetRawText());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("nullVariables")]
|
|
||||||
[InlineData("missingTranslations")]
|
|
||||||
[InlineData("nullTranslation")]
|
|
||||||
[InlineData("duplicateLanguage")]
|
|
||||||
[InlineData("unknownLanguage")]
|
|
||||||
[InlineData("nullActions")]
|
|
||||||
[InlineData("unknownActionField")]
|
|
||||||
[InlineData("blankLabel")]
|
|
||||||
[InlineData("unknownPlaceholder")]
|
|
||||||
[InlineData("invalidRetainedTranslation")]
|
|
||||||
[InlineData("overlap")]
|
|
||||||
[InlineData("unknownRemoval")]
|
|
||||||
[InlineData("emptyRevision")]
|
|
||||||
public async Task InvalidPatchPreservesEntireDefinition(string scenario)
|
|
||||||
{
|
|
||||||
await using var c = await DefinitionApiTestContext.CreateAsync(fixture);
|
|
||||||
await c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en", "uk"));
|
|
||||||
var before = await Read(c);
|
|
||||||
var body = Body(before.GetProperty("revision").GetGuid(), "en");
|
|
||||||
ApplyInvalidCase(body, scenario);
|
|
||||||
var result = await c.Client.PostAsJsonAsync(Url(c, "/upsert"), body);
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
|
|
||||||
Assert.False((await result.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("success").GetBoolean());
|
|
||||||
Assert.Equal(before.GetRawText(), (await Read(c)).GetRawText());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ApplyInvalidCase(JsonObject body, string scenario)
|
|
||||||
{
|
|
||||||
var translation = body["translations"]![0]!;
|
|
||||||
var cases = new Dictionary<string, Action>
|
|
||||||
{
|
|
||||||
["nullVariables"] = () => { body["variables"] = null; },
|
|
||||||
["missingTranslations"] = () => { body.Remove("translations"); },
|
|
||||||
["nullTranslation"] = () => { body["translations"] = new JsonArray((JsonNode?)null); },
|
|
||||||
["duplicateLanguage"] = () => { body["translations"]!.AsArray().Add(translation.DeepClone()); },
|
|
||||||
["unknownLanguage"] = () => { translation["languageCode"] = "fr"; },
|
|
||||||
["nullActions"] = () => { translation["actions"] = null; },
|
|
||||||
["unknownActionField"] = () => { translation["actions"]!["url"] = "ignored"; },
|
|
||||||
["blankLabel"] = () => { translation["actions"]!["buttons"]![0]!["label"] = ""; },
|
|
||||||
["unknownPlaceholder"] = () => { translation["body"] = "{{Unknown}}"; },
|
|
||||||
["invalidRetainedTranslation"] = () => { body["variables"] = new JsonArray(); body["translations"] = new JsonArray(); },
|
|
||||||
["overlap"] = () => { body["removeLanguageCodes"] = new JsonArray("en"); },
|
|
||||||
["unknownRemoval"] = () => { body["removeLanguageCodes"] = new JsonArray("fr"); },
|
|
||||||
["emptyRevision"] = () => { body["revision"] = Guid.Empty.ToString(); },
|
|
||||||
};
|
|
||||||
cases[scenario]();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ConcurrentUpdatesAndCreatesRejectStaleRevisions()
|
|
||||||
{
|
|
||||||
await using var c = await DefinitionApiTestContext.CreateAsync(fixture);
|
|
||||||
var creates = await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(null, "en"))));
|
|
||||||
Assert.Single(creates, r => r.StatusCode == HttpStatusCode.Created);
|
|
||||||
Assert.Single(creates, r => r.StatusCode == HttpStatusCode.Conflict);
|
|
||||||
var revision = (await Read(c)).GetProperty("revision").GetGuid();
|
|
||||||
var updates = await Task.WhenAll(new[] { "de", "uk" }.Select(l => c.Client.PostAsJsonAsync(Url(c, "/upsert"), Body(revision, l))));
|
|
||||||
Assert.Single(updates, r => r.StatusCode == HttpStatusCode.OK);
|
|
||||||
Assert.Single(updates, r => r.StatusCode == HttpStatusCode.Conflict);
|
|
||||||
Assert.Equal(2, (await Read(c)).GetProperty("translations").GetArrayLength());
|
|
||||||
Assert.Equal(HttpStatusCode.Conflict, (await c.Client.PostAsJsonAsync(Url(c, "/upsert", "Missing"), Body(Guid.NewGuid(), "en"))).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.BadRequest, (await c.Client.DeleteAsync(Url(c))).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await c.Client.GetAsync($"/api/v1/services/{Guid.NewGuid()}/push-template-definitions/all")).StatusCode);
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, (await c.Client.PostAsJsonAsync($"/api/v1/services/{Guid.NewGuid()}/push-template-definitions/upsert?key=X", Body(null, "en"))).StatusCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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);
|
var empty = await client.GetStringAsync(baseUrl);
|
||||||
Assert.Contains("No languages configured for this service.", empty);
|
Assert.Contains("No languages configured for this service.", empty);
|
||||||
Assert.Contains("template-key-filter", empty);
|
Assert.Contains("template-key-filter", empty);
|
||||||
Assert.Contains("No templates for this service yet.", empty);
|
Assert.Contains("No email templates for this service yet.", empty);
|
||||||
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
|
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
|
||||||
.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
|
.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
|
||||||
var withDefinition = await client.GetStringAsync(baseUrl);
|
var withDefinition = await client.GetStringAsync(baseUrl);
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
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")]
|
[Route("admin/services/{serviceId:guid}/templates")]
|
||||||
public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions,
|
public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions,
|
||||||
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services, IPushTemplateDefinitionRepository pushes) : Controller
|
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services) : Controller
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||||
|
|
||||||
@@ -27,8 +27,6 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
|||||||
ModelState.AddModelError("", previousError);
|
ModelState.AddModelError("", previousError);
|
||||||
try
|
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))
|
model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct))
|
||||||
.Where(d => d.ServiceId == serviceId).ToList();
|
.Where(d => d.ServiceId == serviceId).ToList();
|
||||||
}
|
}
|
||||||
@@ -41,12 +39,12 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("create")]
|
[HttpGet("create")]
|
||||||
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct, [FromQuery] string? templateKey = null)
|
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var service = await services.GetAsync(serviceId, ct);
|
var service = await services.GetAsync(serviceId, ct);
|
||||||
return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel
|
return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel
|
||||||
{
|
{
|
||||||
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter, Key = templateKey ?? "", LockKey = !string.IsNullOrEmpty(templateKey)
|
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter
|
||||||
}, service);
|
}, service);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +72,6 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
|||||||
var persisted = model.Id is Guid definitionId ? await OwnedDefinitionAsync(service, definitionId, ct) : null;
|
var persisted = model.Id is Guid definitionId ? await OwnedDefinitionAsync(service, definitionId, ct) : null;
|
||||||
if (model.Id is not null && persisted is null)
|
if (model.Id is not null && persisted is null)
|
||||||
return NotFound();
|
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);
|
var presentCodes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||||
foreach (var language in model.Languages)
|
foreach (var language in model.Languages)
|
||||||
language.IsPresent = presentCodes?.Contains(language.LanguageCode) == true;
|
language.IsPresent = presentCodes?.Contains(language.LanguageCode) == true;
|
||||||
|
|||||||
+1
-2
@@ -16,7 +16,6 @@ public sealed class EmailTemplateEditViewModel
|
|||||||
public List<EmailTemplateLanguageEditViewModel> Languages { get; set; } = [];
|
public List<EmailTemplateLanguageEditViewModel> Languages { get; set; } = [];
|
||||||
public int ActiveLanguageIndex { get; set; }
|
public int ActiveLanguageIndex { get; set; }
|
||||||
public string? KeyFilter { get; set; }
|
public string? KeyFilter { get; set; }
|
||||||
public bool LockKey { get; set; }
|
|
||||||
public bool IsNew => Id == null;
|
public bool IsNew => Id == null;
|
||||||
public string PageTitle => IsNew ? "Create Template" : "Template: " + Key;
|
public string PageTitle => IsNew ? "Create Email Template" : "Edit Email Template";
|
||||||
}
|
}
|
||||||
|
|||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
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
@@ -1,38 +0,0 @@
|
|||||||
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,6 +6,5 @@ public sealed class ServiceTemplatesViewModel
|
|||||||
{
|
{
|
||||||
public required NotificationServiceInfo Service { get; init; }
|
public required NotificationServiceInfo Service { get; init; }
|
||||||
public string? KeyFilter { get; init; }
|
public string? KeyFilter { get; init; }
|
||||||
public IReadOnlyList<PushTemplateDefinition> PushDefinitions { get; set; } = [];
|
|
||||||
public IReadOnlyList<EmailTemplateDefinition> Definitions { get; set; } = [];
|
public IReadOnlyList<EmailTemplateDefinition> Definitions { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
using Unit = HrynCo.NotificationService.Services.Core.Unit;
|
|
||||||
using HrynCo.NotificationService.Web.Infrastructure;
|
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
using HrynCo.NotificationService.Services.Core;
|
|
||||||
using HrynCo.NotificationService.Services.TemplateDefinitions;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
|
|
||||||
|
|
||||||
[Route("api/v1/services/{serviceId:guid}/push-template-definitions")]
|
|
||||||
[TypeFilter(typeof(ApiRequestValidationFilter), Order = -3000)]
|
|
||||||
public sealed class PushTemplateDefinitionsController(IMediator mediator) : ApiControllerBase(mediator)
|
|
||||||
{
|
|
||||||
[HttpGet]
|
|
||||||
[ProducesResponseType<ApiResponse<PushTemplateDefinitionDto>>(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> Get([FromRoute] Guid serviceId, [FromQuery] string key, CancellationToken cancellationToken) =>
|
|
||||||
FromServiceResult(await Mediator.Send(new GetPushTemplateDefinitionQuery(serviceId, key), cancellationToken));
|
|
||||||
|
|
||||||
[HttpPost("upsert")]
|
|
||||||
[ProducesResponseType<ApiResponse<PushTemplateDefinitionDto>>(StatusCodes.Status201Created)]
|
|
||||||
[ProducesResponseType<ApiResponse<PushTemplateDefinitionDto>>(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> Upsert([FromRoute] Guid serviceId, [FromQuery] string key,
|
|
||||||
[FromBody] UpsertPushTemplateDefinitionRequest request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var patch = request.ToPatch(serviceId, key);
|
|
||||||
var result = await Mediator.Send(new UpsertPushTemplateDefinitionCommand(patch), cancellationToken);
|
|
||||||
if (!result.IsSuccess)
|
|
||||||
return MapServiceError(result.Error!);
|
|
||||||
if (patch.Revision is null)
|
|
||||||
return CreatedAtAction(nameof(Get), new { serviceId, key },
|
|
||||||
new ApiResponse<PushTemplateDefinitionDto> { Success = true, Data = result.Result });
|
|
||||||
return FromServiceResult(result);
|
|
||||||
}
|
|
||||||
catch (EmailTemplateWriteException error)
|
|
||||||
{
|
|
||||||
return MapServiceError(new ServiceError(error.Message, ServiceErrorCode.InvalidRequest));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
[HttpGet("all")]
|
|
||||||
[ProducesResponseType<ApiResponse<IReadOnlyList<PushTemplateDefinitionDto>>>(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
|
|
||||||
public async Task<IActionResult> GetAll([FromRoute] Guid serviceId, CancellationToken cancellationToken) =>
|
|
||||||
FromServiceResult(await Mediator.Send(new GetServicePushTemplateDefinitionsQuery(serviceId), cancellationToken));
|
|
||||||
|
|
||||||
[HttpDelete]
|
|
||||||
[ProducesResponseType<ApiResponse<Unit>>(StatusCodes.Status200OK)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status400BadRequest)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status404NotFound)]
|
|
||||||
[ProducesResponseType<ApiResponse<object>>(StatusCodes.Status409Conflict)]
|
|
||||||
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, [FromQuery] string key, [FromQuery] Guid revision, CancellationToken cancellationToken) =>
|
|
||||||
FromServiceResult(await Mediator.Send(new DeletePushTemplateDefinitionCommand(serviceId, key, revision), cancellationToken));
|
|
||||||
|
|
||||||
}
|
|
||||||
-11
@@ -1,11 +0,0 @@
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
|
|
||||||
|
|
||||||
public sealed class PushTemplateTranslationRequest
|
|
||||||
{
|
|
||||||
public required string LanguageCode { get; init; }
|
|
||||||
public required string Title { get; init; }
|
|
||||||
public required string Body { get; init; }
|
|
||||||
public PushTemplateActions Actions { get; init; } = new();
|
|
||||||
}
|
|
||||||
-32
@@ -1,32 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Web.Controllers.Api.Services;
|
|
||||||
|
|
||||||
public sealed class UpsertPushTemplateDefinitionRequest
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
|
||||||
public Guid? Revision { get; init; }
|
|
||||||
public required IReadOnlyList<TemplateVariableRequest> Variables { get; init; }
|
|
||||||
public required IReadOnlyList<PushTemplateTranslationRequest> Translations { get; init; }
|
|
||||||
public IReadOnlyList<string>? RemoveLanguageCodes { get; init; }
|
|
||||||
|
|
||||||
public PushTemplateDefinitionPatch ToPatch(Guid serviceId, string key)
|
|
||||||
{
|
|
||||||
if (Variables is null || Translations is null)
|
|
||||||
throw new EmailTemplateWriteException("variables and translations must be JSON arrays, including when empty.", false);
|
|
||||||
if (Variables.Any(v => v is null) || Translations.Any(t => t is null))
|
|
||||||
throw new EmailTemplateWriteException("variables and translations cannot contain null elements.", false);
|
|
||||||
return new PushTemplateDefinitionPatch
|
|
||||||
{
|
|
||||||
ServiceId = serviceId, Key = key, Revision = Revision,
|
|
||||||
Variables = Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(),
|
|
||||||
Translations = Translations.Select(t => new PushTemplateTranslation
|
|
||||||
{
|
|
||||||
LanguageCode = t.LanguageCode, Title = t.Title, Body = t.Body,
|
|
||||||
ActionsJson = JsonSerializer.Serialize(t.Actions, JsonOptions)
|
|
||||||
}).ToList(),
|
|
||||||
RemoveLanguageCodes = RemoveLanguageCodes ?? []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,26 +73,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<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">Tests the current form settings without saving. Blank credentials use the saved credentials only when the Firebase project is unchanged.</p>
|
||||||
<label for="pushTestVersion" class="form-label fw-semibold">Test format</label>
|
<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>
|
||||||
<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>
|
<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>
|
<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>
|
<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 id="pushSendResult" class="alert d-none mt-3" role="status" aria-live="polite"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -117,8 +101,6 @@
|
|||||||
const authorizationButton = document.getElementById('pushAuthorizationBtn');
|
const authorizationButton = document.getElementById('pushAuthorizationBtn');
|
||||||
const sendButton = document.getElementById('pushSendBtn');
|
const sendButton = document.getElementById('pushSendBtn');
|
||||||
const modalButton = document.getElementById('pushTestModalBtn');
|
const modalButton = document.getElementById('pushTestModalBtn');
|
||||||
const version = document.getElementById('pushTestVersion');
|
|
||||||
const binding = document.getElementById('pushTestBindingId');
|
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
|
||||||
function showResult(target, success, message) {
|
function showResult(target, success, message) {
|
||||||
@@ -132,11 +114,7 @@
|
|||||||
channelId: id === '00000000-0000-0000-0000-000000000000' ? null : id,
|
channelId: id === '00000000-0000-0000-0000-000000000000' ? null : id,
|
||||||
firebaseProjectId: form.querySelector('[name="FirebaseProjectId"]').value,
|
firebaseProjectId: form.querySelector('[name="FirebaseProjectId"]').value,
|
||||||
credentialsJson: form.querySelector('[name="CredentialsJson"]').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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,14 +152,10 @@
|
|||||||
|
|
||||||
authorizationButton.addEventListener('click', () => runTest(false));
|
authorizationButton.addEventListener('click', () => runTest(false));
|
||||||
sendButton.addEventListener('click', () => runTest(true));
|
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', () => {
|
modalButton.addEventListener('click', () => {
|
||||||
document.getElementById('pushSendResult').className = 'alert d-none';
|
document.getElementById('pushSendResult').className = 'alert d-none';
|
||||||
bootstrap.Modal.getOrCreateInstance(modal).show();
|
bootstrap.Modal.getOrCreateInstance(modal).show();
|
||||||
});
|
});
|
||||||
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; binding.value = ''; });
|
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
@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>
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
@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>
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
@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
|
@model EmailTemplateDefinitionDeleteViewModel
|
||||||
@{ ViewData["Title"] = "Delete email template"; var returnUrl = Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter }); }
|
@{ ViewData["Title"] = "Delete email template"; var returnUrl = Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter }); }
|
||||||
<div class="admin-delete-confirmation">
|
<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 Email 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 template</li></ol></nav>
|
||||||
<h2>Delete template @Model.Key?</h2>
|
<h2>Delete template @Model.Key?</h2>
|
||||||
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
|
<div asp-validation-summary="All" class="text-danger" role="alert"></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>
|
<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>
|
||||||
<p>Languages: @(Model.LanguageCodes.Count == 0 ? "No translations" : string.Join(", ", Model.LanguageCodes))</p>
|
<p>Languages: @(Model.LanguageCodes.Count == 0 ? "No translations" : string.Join(", ", Model.LanguageCodes))</p>
|
||||||
<p>The Push template, service, language catalog and delivery channels are retained.</p>
|
<p>The service, its language catalog and email channels are retained.</p>
|
||||||
<form asp-action="DeleteDefinition" asp-route-serviceId="@Model.ServiceId" asp-route-definitionId="@Model.DefinitionId" method="post">
|
<form asp-action="DeleteDefinition" asp-route-serviceId="@Model.ServiceId" asp-route-definitionId="@Model.DefinitionId" method="post">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<input asp-for="Revision" type="hidden" /><input asp-for="KeyFilter" type="hidden" />
|
<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 the Email template @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 @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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,14 +29,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label asp-for="Key" class="form-label fw-semibold">Template Key</label>
|
<label asp-for="Key" class="form-label fw-semibold">Template Key</label>
|
||||||
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew || Model.LockKey)" />
|
<input asp-for="Key" class="form-control" readonly="@(!Model.IsNew)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<partial name="_TemplateChannelNavigation" model="@(new TemplateChannelNavigationViewModel { ServiceId = Model.ServiceId, Key = Model.Key, KeyFilter = Model.KeyFilter })" />
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label asp-for="VariablesJson" class="form-label fw-semibold">Shared variables (JSON)</label>
|
<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>
|
<textarea asp-for="VariablesJson" class="form-control font-monospace" rows="14"></textarea>
|
||||||
<div class="form-text">Email variables, shared across its languages: <code>[{"name":"UserName","required":true}]</code></div>
|
<div class="form-text">One contract for every language: <code>[{"name":"UserName","required":true}]</code></div>
|
||||||
</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>
|
<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> }
|
@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> }
|
||||||
@@ -103,7 +102,6 @@
|
|||||||
<a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a>
|
<a id="cancelTemplate" href="@Url.Action("Index", "AdminTemplates", new { serviceId = Model.ServiceId, key = Model.KeyFilter })" class="btn btn-secondary">Cancel</a>
|
||||||
}
|
}
|
||||||
@section Scripts {
|
@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-preview.js" asp-append-version="true"></script>
|
||||||
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
|
<script src="~/js/email-template-editor.js" asp-append-version="true"></script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h3 class="h4">Templates</h3>
|
<h3 class="h4">Email Templates</h3>
|
||||||
<a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a>
|
<a asp-action="Create" asp-route-serviceId="@serviceId" asp-route-keyFilter="@keyFilter" class="btn btn-primary">Create template</a>
|
||||||
</div>
|
</div>
|
||||||
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
<div asp-validation-summary="All" class="text-danger mb-3" role="alert"></div>
|
||||||
@@ -40,13 +40,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Email</th>
|
<th scope="col">Languages</th>
|
||||||
<th scope="col">Push</th>
|
|
||||||
<th scope="col" class="template-actions">Action</th>
|
<th scope="col" class="template-actions">Action</th>
|
||||||
</tr></thead><tbody>
|
</tr></thead><tbody>
|
||||||
@if (Model.Definitions.Count == 0 && Model.PushDefinitions.Count == 0)
|
@if (Model.Definitions.Count == 0)
|
||||||
{
|
{
|
||||||
<tr><td colspan="4"><div class="empty-state">
|
<tr><td colspan="3"><div class="empty-state">
|
||||||
@if (!ViewData.ModelState.IsValid)
|
@if (!ViewData.ModelState.IsValid)
|
||||||
{
|
{
|
||||||
<p class="mb-0">Templates are unavailable. Review the error and try again.</p>
|
<p class="mb-0">Templates are unavailable. Review the error and try again.</p>
|
||||||
@@ -57,21 +56,18 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<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>
|
<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>
|
||||||
}
|
}
|
||||||
</div></td></tr>
|
</div></td></tr>
|
||||||
}
|
}
|
||||||
@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))
|
@foreach (var template in Model.Definitions)
|
||||||
{
|
{
|
||||||
var email = Model.Definitions.SingleOrDefault(d => d.Key == templateKey);
|
var editUrl = Url.Action("Edit", "AdminTemplates", new { serviceId, definitionId = template.Id, keyFilter });
|
||||||
var push = Model.PushDefinitions.SingleOrDefault(d => d.Key == templateKey);
|
var presentCodes = template.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||||
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>
|
<tr>
|
||||||
<td>@templateKey</td>
|
<td>@template.Key</td>
|
||||||
<td>
|
<td>
|
||||||
@if (email is null) { <span>Not configured</span> }
|
@if (Model.Service.Languages.Count == 0)
|
||||||
else if (Model.Service.Languages.Count == 0)
|
|
||||||
{
|
{
|
||||||
<span>No configured languages</span>
|
<span>No configured languages</span>
|
||||||
}
|
}
|
||||||
@@ -88,22 +84,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
@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>
|
</tr>
|
||||||
}
|
}
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<link href="/lib/bootstrap/bootstrap.min.css" rel="stylesheet" />
|
<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"
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
|
||||||
rel="stylesheet" />
|
rel="stylesheet" />
|
||||||
<link href="~/css/admin.css" rel="stylesheet" asp-append-version="true" />
|
<link href="/css/admin.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-dark bg-dark admin-navbar">
|
<nav class="navbar navbar-dark bg-dark admin-navbar">
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
@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,11 +578,3 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
textarea.form-control { resize: vertical; }
|
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 activeField = document.getElementById('ActiveLanguageIndex');
|
||||||
const variablesField = document.getElementById('VariablesJson');
|
const variablesField = document.getElementById('VariablesJson');
|
||||||
const samples = document.getElementById('previewVariables');
|
const samples = document.getElementById('previewVariables');
|
||||||
let dirty = !!form.querySelector('.validation-summary-errors');
|
let dirty = false;
|
||||||
const sampleValues = new Map();
|
const sampleValues = new Map();
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
@@ -77,52 +77,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function interpolate(text, html = false) {
|
function interpolate(text, html = false) {
|
||||||
return text.replace(/\{\{([^{}]+)\}\}/g, (placeholder, name) => {
|
let result = text;
|
||||||
if (!sampleValues.has(name)) return placeholder;
|
samples.querySelectorAll('input').forEach(input => {
|
||||||
const value = sampleValues.get(name);
|
const name = input.dataset.variableName;
|
||||||
return html ? escapeHtml(value) : value;
|
const value = html ? escapeHtml(input.value) : input.value;
|
||||||
|
result = result.split(`{{${name}}}`).join(value);
|
||||||
});
|
});
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
const pane = activePane();
|
const pane = activePane();
|
||||||
const push = !!document.getElementById('previewTitle');
|
if (!pane) {
|
||||||
document.getElementById('previewLanguage').textContent = pane?.querySelector('.language-code').value ?? '(no versions)';
|
document.getElementById('previewLanguage').textContent = '(no versions)';
|
||||||
const heading = pane?.querySelector(push ? '[data-content="title"]' : '[data-content="subject"]')?.value ?? '';
|
document.getElementById('previewSubject').textContent = '';
|
||||||
const body = pane?.querySelector(push ? '[data-content="body"]' : '[data-content="text"]')?.value ?? '';
|
document.getElementById('previewText').textContent = '';
|
||||||
document.getElementById(push ? 'previewTitle' : 'previewSubject').textContent = interpolate(heading);
|
document.getElementById('previewHtmlFrame').srcdoc = '';
|
||||||
document.getElementById(push ? 'previewBody' : 'previewText').textContent = interpolate(body);
|
return;
|
||||||
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);
|
||||||
function updateHtmlPreview(pane) {
|
document.getElementById('previewText').textContent = interpolate(pane.querySelector('[data-content="text"]').value);
|
||||||
const frame = document.getElementById('previewHtmlFrame');
|
const html = interpolate(pane.querySelector('[data-content="html"]').value, true);
|
||||||
if (!frame) return;
|
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>`;
|
||||||
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 => {
|
tabs.addEventListener('click', event => {
|
||||||
@@ -149,25 +126,5 @@
|
|||||||
if (dirty) { event.preventDefault(); event.returnValue = ''; }
|
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();
|
renderSamples();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
(() => {
|
|
||||||
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,40 +4,24 @@ using HrynCo.NotificationService.Contracts.Messages;
|
|||||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
|
||||||
|
|
||||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||||
|
|
||||||
public sealed class FcmPushSender(
|
public sealed class FcmPushSender(
|
||||||
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client,
|
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client) : IPushSender
|
||||||
IPushTemplateDefinitionRepository templates) : IPushSender
|
|
||||||
{
|
{
|
||||||
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken)
|
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.");
|
if (data.ExpiresAt <= DateTimeOffset.UtcNow) return new PushDeliveryOutcome("Push expired.");
|
||||||
var service = await services.GetByNameAsync(data.ServiceName, cancellationToken);
|
var service = await services.GetByNameAsync(data.ServiceName, cancellationToken);
|
||||||
if (service is null) return new PushDeliveryOutcome("Push service is not configured.");
|
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);
|
var channel = await channels.GetActiveForServiceAsync(service.Id, cancellationToken);
|
||||||
if (channel is null) return new PushDeliveryOutcome("No active push channel is configured for this service.");
|
if (channel is null) return new PushDeliveryOutcome("No active push channel is configured for this service.");
|
||||||
var credentials = await channels.GetCredentialsAsync(channel.Id, cancellationToken);
|
var credentials = await channels.GetCredentialsAsync(channel.Id, cancellationToken);
|
||||||
return await SendWithCredentialsAsync(messageId, rendered, channel.FirebaseProjectId, credentials, cancellationToken);
|
return await SendWithCredentialsAsync(messageId, data, channel.FirebaseProjectId, credentials, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<RenderedPushMessageData> RenderAsync(Guid serviceId, SendPushMessageData data, CancellationToken ct)
|
private async Task<PushDeliveryOutcome> SendWithCredentialsAsync(Guid messageId, SendPushMessageData data,
|
||||||
{
|
|
||||||
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)
|
string projectId, string? json, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -3,91 +3,42 @@ namespace HrynCo.NotificationService.Worker.Services.Push;
|
|||||||
using FirebaseAdmin.Messaging;
|
using FirebaseAdmin.Messaging;
|
||||||
using FirebaseAdmin;
|
using FirebaseAdmin;
|
||||||
using Google.Apis.Auth.OAuth2;
|
using Google.Apis.Auth.OAuth2;
|
||||||
using System.Text;
|
using HrynCo.NotificationService.Contracts.Messages;
|
||||||
using System.Text.Json;
|
|
||||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||||
|
|
||||||
public sealed class FirebasePushClient : IFirebasePushClient
|
public sealed class FirebasePushClient : IFirebasePushClient
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
|
||||||
{
|
|
||||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
|
|
||||||
};
|
|
||||||
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data,
|
|
||||||
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
|
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!PushMessageValidator.IsSupportedVersion(data.Delivery.Version))
|
var remaining = data.ExpiresAt - DateTimeOffset.UtcNow;
|
||||||
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.");
|
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
|
return await SendMessageAsync(new Message
|
||||||
{
|
{
|
||||||
Token = data.Delivery.Token,
|
Token = data.Token,
|
||||||
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining },
|
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining },
|
||||||
Data = payload
|
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)
|
||||||
|
}
|
||||||
}, credentials, cancellationToken);
|
}, credentials, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static Dictionary<string, string> BuildPayload(Guid messageId, RenderedPushMessageData data)
|
public Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials,
|
||||||
{
|
CancellationToken cancellationToken) => SendMessageAsync(new Message
|
||||||
var payload = BasePayload(messageId, data.Delivery.BindingId, data.Delivery.ExpiresAt, data.Title, data.Body);
|
|
||||||
switch (data.Delivery.Version)
|
|
||||||
{
|
{
|
||||||
case 1:
|
Token = token,
|
||||||
payload["version"] = "1";
|
Notification = new Notification
|
||||||
payload["route"] = data.Delivery.Route;
|
{
|
||||||
break;
|
Title = "Notification Service test",
|
||||||
case 2:
|
Body = "This is a test notification from the channel administration page."
|
||||||
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) }
|
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,
|
public async Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace HrynCo.NotificationService.Worker.Services.Push;
|
|||||||
public interface IFirebasePushClient
|
public interface IFirebasePushClient
|
||||||
{
|
{
|
||||||
Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
|
Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
|
||||||
Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
|
Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
|
||||||
Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data,
|
Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
|
||||||
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
|
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
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,17 +9,13 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
|
|||||||
{
|
{
|
||||||
public async Task<PushDeliveryOutcome> TestAsync(PushChannelTestSettings settings, bool send, CancellationToken ct)
|
public async Task<PushDeliveryOutcome> TestAsync(PushChannelTestSettings settings, bool send, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var message = new PushChannelTestMessage
|
if (send && !ValidToken(settings.RecipientToken))
|
||||||
{
|
return new PushDeliveryOutcome("Enter a valid recipient FCM token.");
|
||||||
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 project = settings.FirebaseProjectId?.Trim() ?? "";
|
||||||
var json = await ResolveCredentialsAsync(settings, project, ct);
|
var json = await ResolveCredentialsAsync(settings, project, ct);
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
return new PushDeliveryOutcome("Provide credentials for this project. Saved credentials can only be used with the unchanged project.");
|
return new PushDeliveryOutcome("Provide credentials for this project. Saved credentials can only be used with the unchanged project.");
|
||||||
return await ExecuteAsync(json, project, message, send, ct);
|
return await ExecuteAsync(json, project, settings.RecipientToken, send, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> ResolveCredentialsAsync(PushChannelTestSettings settings, string project, CancellationToken ct)
|
private async Task<string?> ResolveCredentialsAsync(PushChannelTestSettings settings, string project, CancellationToken ct)
|
||||||
@@ -30,12 +26,12 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
|
|||||||
return saved?.FirebaseProjectId == project ? await channels.GetCredentialsAsync(id, ct) : null;
|
return saved?.FirebaseProjectId == project ? await channels.GetCredentialsAsync(id, ct) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<PushDeliveryOutcome> ExecuteAsync(string json, string project, PushChannelTestMessage message, bool send, CancellationToken ct)
|
private async Task<PushDeliveryOutcome> ExecuteAsync(string json, string project, string? token, bool send, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var credentials = FirebaseServiceAccountCredentials.Parse(json, project);
|
var credentials = FirebaseServiceAccountCredentials.Parse(json, project);
|
||||||
return send ? await firebase.SendTestAsync(message, credentials, ct)
|
return send ? await firebase.SendTestAsync(token!.Trim(), credentials, ct)
|
||||||
: await firebase.CheckAuthorizationAsync(credentials, ct);
|
: await firebase.CheckAuthorizationAsync(credentials, ct);
|
||||||
}
|
}
|
||||||
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
|
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
|
||||||
@@ -48,4 +44,6 @@ 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,8 +6,4 @@ public sealed class PushChannelTestSettings
|
|||||||
public string? FirebaseProjectId { get; set; }
|
public string? FirebaseProjectId { get; set; }
|
||||||
public string? CredentialsJson { get; set; }
|
public string? CredentialsJson { get; set; }
|
||||||
public string? RecipientToken { 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 },
|
CorrelationContext = message.CorrelationContext with { ReplyTo = null },
|
||||||
Data = new NotificationResultData
|
Data = new NotificationResultData
|
||||||
{
|
{
|
||||||
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = message.Data.Version == 1 ? "PushDiagnostic" : message.Data.TemplateKey!,
|
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = "PushDiagnostic",
|
||||||
Timestamp = DateTimeOffset.UtcNow, ErrorMessage = outcome.Error,
|
Timestamp = DateTimeOffset.UtcNow, ErrorMessage = outcome.Error,
|
||||||
PushRegistrationId = message.Data.RegistrationId, PushBindingId = message.Data.BindingId,
|
PushRegistrationId = message.Data.RegistrationId, PushBindingId = message.Data.BindingId,
|
||||||
PushTokenHash = message.Data.TokenHash, InvalidPushRegistration = outcome.InvalidRegistration
|
PushTokenHash = message.Data.TokenHash, InvalidPushRegistration = outcome.InvalidRegistration
|
||||||
|
|||||||
@@ -8,27 +8,11 @@ public static class PushMessageValidator
|
|||||||
{
|
{
|
||||||
if (message?.Data is not { } data || message.CorrelationContext is null) return false;
|
if (message?.Data is not { } data || message.CorrelationContext is null) return false;
|
||||||
return Guid.TryParse(message.CorrelationContext.CorrelationId, out _) &&
|
return Guid.TryParse(message.CorrelationContext.CorrelationId, out _) &&
|
||||||
data.RegistrationId != Guid.Empty && data.BindingId != Guid.Empty &&
|
data.Version == 1 && data.RegistrationId != Guid.Empty && data.BindingId != Guid.Empty &&
|
||||||
data.ServiceName is { Length: > 0 and <= 100 } && data.UserId != 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.Token is { Length: > 0 and <= 2048 } && data.TokenHash is { Length: 64 } &&
|
||||||
IsValidContent(data) && data.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(15);
|
data.Title is { Length: > 0 and <= 100 } && data.Body is { Length: > 0 and <= 500 } &&
|
||||||
|
data.Route == "/home" && 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 }));
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
|
||||||
|
|
||||||
public sealed record RenderedPushAction(string Action, string? ObjectId = null, string? Label = null);
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
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,11 +14,6 @@ public sealed class SendPushConsumer(
|
|||||||
|
|
||||||
protected override bool TryValidateMessage(SendPushMessage message, RabbitMqMessageContext context, out string? validationError)
|
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);
|
bool valid = SendPushDeliveryValidator.IsValid(message, context);
|
||||||
validationError = valid ? null : "Invalid push envelope.";
|
validationError = valid ? null : "Invalid push envelope.";
|
||||||
return valid;
|
return valid;
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ Production image references now use `registry.hrynco.com`, preserving the existi
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Email and push template model](docs/channel-template-model.md)
|
|
||||||
|
|
||||||
- [Push channels and Android FCM](docs/android-push.md)
|
- [Push channels and Android FCM](docs/android-push.md)
|
||||||
- [Email template definitions and service languages](docs/email-template-localizations.md)
|
- [Email template definitions and service languages](docs/email-template-localizations.md)
|
||||||
- [Service and whole-template API for AI clients](docs/email-template-definition-api.md)
|
- [Service and whole-template API for AI clients](docs/email-template-definition-api.md)
|
||||||
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
|
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
|
||||||
- [Shared Notification Service Kubernetes package](docker/kubernetes/shared-notification/README.md)
|
- [Shared Notification Service Kubernetes package](docker/kubernetes/shared-notification/README.md)
|
||||||
|
- [Production operations and recovery runbook](docs/production-operations.md) — actual Kubernetes environment, backup/restore, grants, Google egress and owner access.
|
||||||
|
|
||||||
## Windows host development
|
## Windows host development
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Shared Notification Service on MicroK8s
|
# Shared Notification Service on MicroK8s
|
||||||
|
|
||||||
|
For the current six-migration production release, use the
|
||||||
|
[production operations and recovery runbook](../../../docs/production-operations.md).
|
||||||
|
The original bootstrap/deploy schema below predates push channels; do not run its
|
||||||
|
full deploy against the current database. The current additive Google HTTPS policy
|
||||||
|
is `google-api-network.json`; refresh it with `refresh-google-api-network.py` and
|
||||||
|
review/server-dry-run it before applying. Existing SMTP and broker policies remain.
|
||||||
|
|
||||||
This package deploys one shared Web/Worker, PostgreSQL 17, RabbitMQ 4 and a temporary
|
This package deploys one shared Web/Worker, PostgreSQL 17, RabbitMQ 4 and a temporary
|
||||||
Mailpit sandbox in `shared-notification`, on `mini-pc-2`. It does not expose public
|
Mailpit sandbox in `shared-notification`, on `mini-pc-2`. It does not expose public
|
||||||
Ingress, NodePort or unauthenticated administration. Application administration,
|
Ingress, NodePort or unauthenticated administration. Application administration,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Render Google API IPv4 egress; never applies changes to Kubernetes."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ipaddress
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(name):
|
||||||
|
with urllib.request.urlopen(f"https://www.gstatic.com/ipranges/{name}.json", timeout=20) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
|
||||||
|
def api_networks(google, cloud):
|
||||||
|
networks = [ipaddress.ip_network(p["ipv4Prefix"]) for p in google["prefixes"] if "ipv4Prefix" in p]
|
||||||
|
for prefix in cloud["prefixes"]:
|
||||||
|
if "ipv4Prefix" not in prefix:
|
||||||
|
continue
|
||||||
|
excluded = ipaddress.ip_network(prefix["ipv4Prefix"])
|
||||||
|
remaining = []
|
||||||
|
for network in networks:
|
||||||
|
if not network.overlaps(excluded):
|
||||||
|
remaining.append(network)
|
||||||
|
elif not network.subnet_of(excluded):
|
||||||
|
remaining.extend(network.address_exclude(excluded))
|
||||||
|
networks = remaining
|
||||||
|
return list(ipaddress.collapse_addresses(networks))
|
||||||
|
|
||||||
|
|
||||||
|
def render(google, cloud):
|
||||||
|
networks = api_networks(google, cloud)
|
||||||
|
if not networks or any(not network.is_global for network in networks):
|
||||||
|
raise ValueError("Expected nonempty public Google API IPv4 ranges")
|
||||||
|
labels = {"app.kubernetes.io/name": "hrynco-shared-notification",
|
||||||
|
"app.kubernetes.io/managed-by": "hrynco-shared-notification"}
|
||||||
|
return {
|
||||||
|
"apiVersion": "networking.k8s.io/v1", "kind": "NetworkPolicy",
|
||||||
|
"metadata": {"name": "web-worker-to-google-apis", "namespace": "shared-notification",
|
||||||
|
"labels": labels, "annotations": {
|
||||||
|
"hrynco.com/google-ranges-date": google["creationTime"],
|
||||||
|
"hrynco.com/cloud-ranges-date": cloud["creationTime"]}},
|
||||||
|
"spec": {"podSelector": {"matchLabels": labels, "matchExpressions": [
|
||||||
|
{"key": "app.kubernetes.io/component", "operator": "In", "values": ["web", "worker"]}]},
|
||||||
|
"policyTypes": ["Egress"], "egress": [{
|
||||||
|
"to": [{"ipBlock": {"cidr": str(network)}} for network in networks],
|
||||||
|
"ports": [{"protocol": "TCP", "port": 443}]}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
policy = render(fetch("goog"), fetch("cloud"))
|
||||||
|
args.output.write_text(json.dumps(policy, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"Rendered {len(policy['spec']['egress'][0]['to'])} IPv4 ranges; review before applying.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -166,28 +166,3 @@ runtime validation remain separate pending work. TeamCity main triggers include
|
|||||||
checks, image publication, Contracts publication and production deployment. The owner
|
checks, image publication, Contracts publication and production deployment. The owner
|
||||||
explicitly authorized this automatic production consequence for the Notification Service
|
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.
|
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.
|
|
||||||
|
|||||||
@@ -1,344 +0,0 @@
|
|||||||
# 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.
|
|
||||||
|
|
||||||
## Push template management REST API (IT-1627, 2026-10-31)
|
|
||||||
|
|
||||||
Push definitions now have the same service-scoped management operations as Email:
|
|
||||||
|
|
||||||
| Method | Route | Purpose |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/api/v1/services/{serviceId}/push-template-definitions?key={key}` | Read one exact key |
|
|
||||||
| GET | `/api/v1/services/{serviceId}/push-template-definitions/all` | List Push definitions |
|
|
||||||
| POST | `/api/v1/services/{serviceId}/push-template-definitions/upsert?key={key}` | Create or atomically patch a definition |
|
|
||||||
| DELETE | `/api/v1/services/{serviceId}/push-template-definitions?key={key}&revision={revision}` | Delete only Push, preserving Email |
|
|
||||||
|
|
||||||
Requests and responses use camelCase and the existing `success/data/error` envelope.
|
|
||||||
Create with omitted/null `revision`; an existing key returns 409. Update with the
|
|
||||||
last read nonempty revision; stale/missing definitions return 409. Successful
|
|
||||||
creation returns 201 with Location; update/read/delete return 200. Unknown services
|
|
||||||
or absent reads/deletes return 404; invalid requests return 400.
|
|
||||||
|
|
||||||
Example creation body:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"revision": null,
|
|
||||||
"variables": [{ "name": "ActorName", "required": true }],
|
|
||||||
"translations": [{
|
|
||||||
"languageCode": "uk",
|
|
||||||
"title": "Довірені контакти",
|
|
||||||
"body": "{{ActorName}} додав вас до довірених контактів.",
|
|
||||||
"actions": {
|
|
||||||
"defaultAction": "contacts",
|
|
||||||
"buttons": [{ "action": "contacts", "label": "Перейти в розділ" }]
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
"removeLanguageCodes": []
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The action key above illustrates a producer/client agreement; this endpoint neither
|
|
||||||
registers actions nor activates any Invemory producer. Object IDs are per-message
|
|
||||||
metadata, not template content. `actions` is a structured object, not an escaped JSON
|
|
||||||
string. Omission means no actions for a supplied translation; explicit null is invalid.
|
|
||||||
|
|
||||||
`variables` and `translations` arrays are required (empty is valid). Variables replace
|
|
||||||
the channel-wide contract. Supplied translations replace their full content, including
|
|
||||||
actions. Omitted translations remain; only `removeLanguageCodes` removes versions.
|
|
||||||
Removing every language retains the definition and variables. Codes exactly match the
|
|
||||||
service catalog; duplicate/unknown codes and simultaneous replace/remove are rejected.
|
|
||||||
The final merged definition is validated, including retained translations' placeholders,
|
|
||||||
so a variable change cannot invalidate an omitted translation. Invalid/stale requests
|
|
||||||
leave content and revision unchanged. The existing transaction/advisory lock covers
|
|
||||||
catalog checks, merge and revision-guarded persistence. API and administration share
|
|
||||||
validation and storage, including explicit EF insertion when adding a new language.
|
|
||||||
|
|
||||||
Existing Email endpoints remain unchanged. Both channels can independently use the
|
|
||||||
same service/key. No migration, new registry, access boundary or Android change is
|
|
||||||
needed for these management endpoints. Keep them within the existing private
|
|
||||||
Notification Service administration network boundary.
|
|
||||||
|
|
||||||
Validation of the management API increment: 84 service/unit and 187 PostgreSQL
|
|
||||||
integration tests passed (271 total). After review cleanup, all 29 focused Push API,
|
|
||||||
editor and model tests passed again. Coverage includes parallel create/update conflicts,
|
|
||||||
atomic invalid writes, retained translations, structured localized actions, explicit
|
|
||||||
language removal and Email preservation. Rider reported no errors in inspected files;
|
|
||||||
manual structural complexity review was performed, with no numeric score recorded.
|
|
||||||
Reuse review compared Email definition controllers/handlers, API validation/envelopes,
|
|
||||||
TemplateMutationRunner and the existing Push editor/storage path.
|
|
||||||
|
|
||||||
A separate local Web launch for additional browser validation was rejected by the
|
|
||||||
automatic execution policy without further reason. No standalone-browser or installed
|
|
||||||
Android validation is claimed for this increment; API requests were exercised through
|
|
||||||
the ASP.NET integration host with real PostgreSQL. Changes await owner testing and
|
|
||||||
remain uncommitted; the previously delivered service does not yet include these endpoints.
|
|
||||||
|
|
||||||
Owner follow-up: authorized code delivery and another Kubernetes deployment after
|
|
||||||
verification, and authorized stopping the Rider Web process. The Rider process was
|
|
||||||
stopped. A second background Web launch was rejected by the execution tool with only
|
|
||||||
`blocked by policy`; this is an execution-tool restriction, not a request for more
|
|
||||||
owner permission. The automated API/PostgreSQL checks remain available.
|
|
||||||
@@ -111,12 +111,3 @@ 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.
|
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).
|
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.
|
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
# Notification Service: production operations and recovery
|
||||||
|
|
||||||
|
Verified against the running Kubernetes service on **2026-10-24**. Commands below
|
||||||
|
distinguish read-only checks, service interruptions and destructive recovery. Do not
|
||||||
|
paste passwords, service-account JSON, tokens, Secret YAML or database dumps into
|
||||||
|
tickets, chat, Git or CI logs. The administrative UI has no application authentication;
|
||||||
|
keep it behind the existing loopback SSH tunnel.
|
||||||
|
|
||||||
|
## 1. Actual environment and source of truth
|
||||||
|
|
||||||
|
| Item | Actual value |
|
||||||
|
| --- | --- |
|
||||||
|
| Windows source checkout | `C:\projects\HrynCo\hrynco-notification-service` |
|
||||||
|
| SSH target | `devops@192.168.2.44` (`mini-pc-2`; Linux username is lowercase) |
|
||||||
|
| Windows SSH key | `C:\Users\agryn\.ssh\id_ed25519_devops`, unlocked in the owner's ssh-agent |
|
||||||
|
| Kubernetes CLI | `/snap/microk8s/current/kubectl` |
|
||||||
|
| Kubeconfig / context | `/home/devops/.kube/cluster-admin.conf` / `microk8s` |
|
||||||
|
| Namespace / CNI | `shared-notification` / Calico `v3.29.3` |
|
||||||
|
| Server package | `/home/devops/notification-package` |
|
||||||
|
| Private files / local backups | `/home/devops/shared-notification-private` |
|
||||||
|
| External storage | `/mnt/shared/notification-service`, XFS, UUID `c2c31640-a247-4988-bc7b-9ee9789464d1` |
|
||||||
|
| Local admin URL | `http://localhost:15200/admin/services` |
|
||||||
|
| Web service | `web:8080`, ClusterIP; no public Ingress or NodePort |
|
||||||
|
| PostgreSQL | `postgres:5432`, database `notification_service` |
|
||||||
|
| RabbitMQ | `rabbitmq:5672`, vhost `/notification`, management port `15672` |
|
||||||
|
| Seq | `http://seq.observability.svc.cluster.local:8080` |
|
||||||
|
| Sandbox SMTP / UI | `mailpit:1025` / `mailpit:8025`; no external relay |
|
||||||
|
|
||||||
|
The repository's `docker/kubernetes/shared-notification/` owns the package. The
|
||||||
|
server's `config.local.json` supplies the verified host/storage and namespace values;
|
||||||
|
`artifacts.local.json` still describes the older bootstrap application images.
|
||||||
|
`notification.py` owns the original foundation and credential bootstrap. Its
|
||||||
|
`MIGRATIONS`, sandbox SQL and application artifact file are **not yet reconciled with
|
||||||
|
the six-migration release below**. Do not run its full `bootstrap` or `deploy` command
|
||||||
|
over this production release. It can overwrite current image/configuration intent or
|
||||||
|
fail on removed `email_channels.service_name`.
|
||||||
|
|
||||||
|
Use the targeted procedures in this document for the existing production resources.
|
||||||
|
They do not depend on the historical one-off `it1074-deploy.py`. The original
|
||||||
|
deployment audit is retained privately at
|
||||||
|
`/home/devops/shared-notification-private/it1074/{before,result}.json`; `before.json`
|
||||||
|
contains deployment specifications and fingerprints, not a restorable database backup.
|
||||||
|
The one-off helper's initial GRANT used the wrong table case; do not rerun it.
|
||||||
|
|
||||||
|
Network source of truth is additive: base policies from `notification.py`, existing
|
||||||
|
`production-network.yaml` for SMTP and production broker access, and
|
||||||
|
`google-api-network.json` for Google APIs. Never reconcile the sandbox render as the
|
||||||
|
complete production setup. Reconcile and review all overlays together.
|
||||||
|
|
||||||
|
## 2. Connect and inspect before changing anything
|
||||||
|
|
||||||
|
Run in **Windows PowerShell** (no SSH alias is required or assumed):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -o StrictHostKeyChecking=yes -o HostKeyAlgorithms=ssh-ed25519 -o ConnectTimeout=10 -i C:/Users/agryn/.ssh/id_ed25519_devops devops@192.168.2.44
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside that **Linux SSH session**, define these session-local helpers. All subsequent
|
||||||
|
`k` and `kn` commands in this document run there, not in Windows PowerShell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
k() { /snap/microk8s/current/kubectl --kubeconfig=/home/devops/.kube/cluster-admin.conf --request-timeout=20s "$@"; }
|
||||||
|
kn() { k --namespace=shared-notification "$@"; }
|
||||||
|
cd /home/devops/notification-package
|
||||||
|
k config current-context
|
||||||
|
k get node mini-pc-2
|
||||||
|
kn get deployment,pod,service,pvc,job
|
||||||
|
k get pv shared-notification-postgres shared-notification-rabbitmq
|
||||||
|
findmnt -T /mnt/shared/notification-service -o TARGET,SOURCE,FSTYPE,UUID
|
||||||
|
kn get resourcequota
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: context `microk8s`, node Ready, the exact XFS UUID above, both PVCs Bound.
|
||||||
|
Stop if the disk is not mounted: do not let an empty directory on the root disk stand
|
||||||
|
in for persistent storage. PVs use `Retain` and node affinity to `mini-pc-2`:
|
||||||
|
|
||||||
|
| PVC | PV | Path | Capacity |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `postgres-data` | `shared-notification-postgres` | `/mnt/shared/notification-service/postgres` | 10Gi |
|
||||||
|
| `rabbitmq-data` | `shared-notification-rabbitmq` | `/mnt/shared/notification-service/rabbitmq` | 5Gi |
|
||||||
|
|
||||||
|
Do not delete PVCs, clear PV claimRefs, reformat storage or regenerate credentials to
|
||||||
|
repair an application timeout. These are singleton workloads, not an HA database/broker.
|
||||||
|
The quota allows five Deployments and three Jobs; the completed migrator/grants/sandbox
|
||||||
|
Jobs can occupy all three Job slots. A new fourth Job can fail admission.
|
||||||
|
|
||||||
|
## 3. Current application release and infrastructure images
|
||||||
|
|
||||||
|
Application source: `d4ee8fc0c8036b0b48e1a481ef1a12a6a1c1c269`, merged PR 28.
|
||||||
|
Image build: TeamCity `141783`, tag `main-34`. Deploy immutable digests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
WEB_IMAGE='registry.hrynco.com/hrynco.notification-service.web@sha256:69c51ac1ba7c1101472a0b7d45153544a95565cc544bba49908b1e4079195911'
|
||||||
|
WORKER_IMAGE='registry.hrynco.com/hrynco.notification-service.worker@sha256:cc93cbfd82e1d7ff4d88845dfa90c8a1188c51d35775432d385fa5f23795a6a3'
|
||||||
|
MIGRATOR_IMAGE='registry.hrynco.com/hrynco.notification-service.migrator@sha256:9975e05cb075f45a6a629233af4c56194731378f710feb719b205a68bebdb035'
|
||||||
|
```
|
||||||
|
|
||||||
|
The repositories include `notification-service` with a hyphen. Old bootstrap images
|
||||||
|
used `notificationservice`; do not accidentally choose those. These digests are OCI
|
||||||
|
indexes; the matching amd64 manifests are web `d69a6d7cdbc8c08d5a1234933a88579fd176a72b8c64fa02167c42d988eed0be`,
|
||||||
|
worker `92d454a4139de55f5619070ed6b070c94db05749bbd3aca5d0e91d5a11ae8875`,
|
||||||
|
migrator `a94766802609f4348adba4bdef4bc528275f317c3015b10bb97c8d530749adbd`.
|
||||||
|
Registry manifest inspection must accept OCI indexes; a Docker-only Accept header can
|
||||||
|
return a misleading 404.
|
||||||
|
|
||||||
|
Retained infrastructure digests (do not upgrade during application recovery):
|
||||||
|
|
||||||
|
| Image repository | Digest |
|
||||||
|
| --- | --- |
|
||||||
|
| `registry.hrynco.com/mirror/postgres` | `sha256:e31e3d5327d1806f6177827c9710643e4f35f7ab3f14d26d05332753d3e95ee0` |
|
||||||
|
| `registry.hrynco.com/mirror/rabbitmq` | `sha256:ad995f859fce23a0ccd91c24cfac89d8f8cc57de4d708b58dcecea2697ad0d8c` |
|
||||||
|
| `registry.hrynco.com/mirror/mailpit` | `sha256:13de4ffbd28f3089be6dff32afbc8210edb5a593d738fa877ae7272cc73aeb67` |
|
||||||
|
|
||||||
|
Read-only image and readiness verification:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn get deployment web worker -o custom-columns='NAME:.metadata.name,DESIRED:.spec.replicas,READY:.status.readyReplicas,IMAGE:.spec.template.spec.containers[*].image'
|
||||||
|
kn get pods -o custom-columns='NAME:.metadata.name,READY:.status.containerStatuses[*].ready,RESTARTS:.status.containerStatuses[*].restartCount,IMAGEID:.status.containerStatuses[*].imageID'
|
||||||
|
kn rollout status deployment/web --timeout=240s
|
||||||
|
kn rollout status deployment/worker --timeout=240s
|
||||||
|
```
|
||||||
|
|
||||||
|
Web readiness queries `/api/v1/email-channels?serviceName=Invemory.Staging.Sandbox` on
|
||||||
|
8080, every 10 seconds with a one-second timeout. Cold start under the 250m CPU limit
|
||||||
|
can initially time out. Worker has no HTTP/readiness probe: **Ready is not proof of
|
||||||
|
consumer readiness**. Verify RabbitMQ separately.
|
||||||
|
|
||||||
|
## 4. Configuration and credentials
|
||||||
|
|
||||||
|
Both Web and Worker use `notification-config` through `envFrom`:
|
||||||
|
`DOTNET_ENVIRONMENT=Production`, `ASPNETCORE_ENVIRONMENT=Production`,
|
||||||
|
`ASPNETCORE_HTTP_PORTS=8080`, RabbitMQ host `rabbitmq`, port `5672`, vhost
|
||||||
|
`/notification`, Seq URL above, Serilog service `HrynCo.NotificationService` and
|
||||||
|
environment `Shared`. Inspect nonsecret settings with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn get configmap notification-config -o yaml
|
||||||
|
kn get secret
|
||||||
|
```
|
||||||
|
|
||||||
|
`notification-private` keys: `ownerConnectionString`, `runtimeConnectionString`,
|
||||||
|
`postgresPassword`, `runtimePassword`, `rabbitAdminPassword`, `rabbitWorkerPassword`,
|
||||||
|
`rabbitPublisherPassword`, `seqApiKey`. Web/Worker use the runtime connection;
|
||||||
|
migrations use owner connection. Worker username is `notification_worker`.
|
||||||
|
`registry-pull` contains `.dockerconfigjson`; `rabbit-definitions` contains
|
||||||
|
`definitions.json` with sensitive password hashes. Do not print their values.
|
||||||
|
|
||||||
|
The server private directory retains `credentials.json` and production publisher
|
||||||
|
password/hash files. Registry authentication and Seq source files are under
|
||||||
|
`/home/devops/notification-private/registry-auth.json` and `seq-key.txt`.
|
||||||
|
Recover original credentials from a protected backup if lost; changing Kubernetes
|
||||||
|
Secrets does not automatically change passwords in existing database/broker volumes.
|
||||||
|
Do not run bootstrap to generate replacements against retained data.
|
||||||
|
|
||||||
|
Firebase service-account JSON is stored in PostgreSQL `push_channels.credentials`
|
||||||
|
(JSONB), without application encryption, by explicit owner decision. Database backups
|
||||||
|
therefore contain private keys. Edit forms never return the saved JSON; leaving the
|
||||||
|
field blank retains it. A project change requires matching new credentials. Current
|
||||||
|
channel is attached to `Invemory-Prod`, Firebase project technical ID
|
||||||
|
`storemate-hrynco-com`. Do not copy local development keys into production.
|
||||||
|
|
||||||
|
`Check Firebase authorization` obtains an OAuth token using saved credentials when
|
||||||
|
the project is unchanged. It does not send a push, prove FCM send permission, or prove
|
||||||
|
device display. `Send test push` is a separate explicitly authorized action.
|
||||||
|
The standalone test sends a Firebase `notification` payload without Invemory binding
|
||||||
|
data. On the currently installed Invemory Android build it displays through the FCM
|
||||||
|
SDK in background. In foreground, `InvemoryMessagingService.onMessageReceived` rejects
|
||||||
|
the empty data map because `bindingId`, `version`, `messageId`, route and expiry are
|
||||||
|
required; it does not call the base Capacitor handler. Thus accepted test messages can
|
||||||
|
be invisible while Invemory is open. Samsung logs on 2026-10-24 show the FCM fallback
|
||||||
|
notification channel and removal at 13:04:28 Europe/Kiev; the receiver does not log
|
||||||
|
its rejection, so no individual foreground callback was proven. Do not diagnose this
|
||||||
|
as egress failure or rotate tokens solely because foreground display is absent.
|
||||||
|
The Worker push consumer is **not enabled** by this rollout: configuration and broker
|
||||||
|
ACLs still cover email only. A saved active channel and successful OAuth check do not
|
||||||
|
activate end-to-end production push processing. Do not enable it incidentally during
|
||||||
|
an email service recovery.
|
||||||
|
|
||||||
|
## 5. Database history and runtime grants
|
||||||
|
|
||||||
|
Read-only history check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn exec deployment/postgres -- psql -U notification_owner -d notification_service -At -c 'SELECT "MigrationId" FROM "__EFMigrationsHistory" ORDER BY "MigrationId"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected, in order:
|
||||||
|
|
||||||
|
```text
|
||||||
|
20260501214629_InitialCreate
|
||||||
|
20260502154249_PendingChanges
|
||||||
|
20261017072410_EmailTemplateLocalizations
|
||||||
|
20261023223753_AddPushDeliveries
|
||||||
|
20261024073202_LinkEmailChannelsToServices
|
||||||
|
20261024080457_AddPushChannels
|
||||||
|
```
|
||||||
|
|
||||||
|
`notification_owner` is currently the PostgreSQL bootstrap superuser; keep it out of
|
||||||
|
application connections. `notification_runtime` is a login with no superuser,
|
||||||
|
createdb or createrole privileges. There are **no default ACL entries**: future tables
|
||||||
|
do not inherit runtime CRUD automatically. For each new migration, grant only the
|
||||||
|
tables needed by the application and persist the same SQL in `runtime-grants`.
|
||||||
|
Do not add blanket default privileges as a workaround.
|
||||||
|
|
||||||
|
The exact current push grants are below. `"PushDeliveries"` is case-sensitive;
|
||||||
|
`push_deliveries` does not exist. This command is idempotent and was verified:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn exec deployment/postgres -- psql -U notification_owner -d notification_service -v ON_ERROR_STOP=1 -c 'GRANT SELECT, INSERT, UPDATE, DELETE ON push_channels, "PushDeliveries" TO notification_runtime;'
|
||||||
|
kn exec deployment/postgres -- psql -U notification_owner -d notification_service -At -c "SELECT t,has_table_privilege('notification_runtime',t,'SELECT,INSERT,UPDATE,DELETE') FROM unnest(ARRAY['push_channels','\"PushDeliveries\"']) t"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both checks must return `t`. Existing runtime CRUD covers `email_channels`,
|
||||||
|
`email_templates`, `email_channel_usage`, `services`, `service_languages`,
|
||||||
|
`email_template_definitions`; schema USAGE and database CONNECT are granted, schema
|
||||||
|
CREATE is revoked from PUBLIC. The live `runtime-grants` ConfigMap already includes
|
||||||
|
the correct push grant. To persist it again without rotating passwords:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, subprocess
|
||||||
|
k = ['/snap/microk8s/current/kubectl', '--kubeconfig=/home/devops/.kube/cluster-admin.conf', '-n', 'shared-notification']
|
||||||
|
value = json.loads(subprocess.check_output(k + ['get','configmap','runtime-grants','-o','json']))
|
||||||
|
grant = 'GRANT SELECT, INSERT, UPDATE, DELETE ON push_channels, "PushDeliveries" TO notification_runtime;'
|
||||||
|
sql = value['data']['grants.sql']
|
||||||
|
if grant not in sql:
|
||||||
|
subprocess.run(k + ['patch','configmap','runtime-grants','--type=merge','-p',json.dumps({'data':{'grants.sql':sql+'\n'+grant+'\n'}})], check=True)
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not execute the entire grants ConfigMap manually without its intended
|
||||||
|
`RUNTIME_PASSWORD` secret environment: its bootstrap SQL also manages the runtime role
|
||||||
|
password. Do not rerun the old sandbox job; channel ownership now uses `service_id`.
|
||||||
|
The migration maps the legacy `StoreMate-Prod` channel to existing `Invemory-Prod`.
|
||||||
|
The old identifier is historical migration input, not a new channel/service name.
|
||||||
|
|
||||||
|
## 6. Backup before migration or restore
|
||||||
|
|
||||||
|
**Service interruption:** stop Web and Worker writers first. This does not stop
|
||||||
|
external publishers; coordinate a publisher pause if a consistent broker/database
|
||||||
|
recovery point is needed. Never purge queues as a substitute.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn scale deployment/worker deployment/web --replicas=0
|
||||||
|
kn wait --for=delete pod -l 'app.kubernetes.io/component in (web,worker)' --timeout=120s
|
||||||
|
```
|
||||||
|
|
||||||
|
The canonical backup implementation is safe independently of its old deploy schema.
|
||||||
|
This maintained source command creates a custom-format dump with mode 0600, checks
|
||||||
|
the `PGDMP` signature and `pg_restore --list`, and writes SHA-256, bytes and migration
|
||||||
|
history to `manifest.json`. Run from the server package; only these existing functions
|
||||||
|
are used, not `deploy` or sandbox SQL:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 - <<'PY'
|
||||||
|
import types
|
||||||
|
import notification as n
|
||||||
|
c = n.Cluster(types.SimpleNamespace(kubectl='/snap/microk8s/current/kubectl', kubeconfig='/home/devops/.kube/cluster-admin.conf'))
|
||||||
|
with n.lock('/home/devops/shared-notification-private'):
|
||||||
|
assert all(c.get('deployment', x)['spec']['replicas'] == 0 for x in ('web','worker'))
|
||||||
|
print(n.backup(c, '/home/devops/shared-notification-private'))
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the returned folder. Pre-migration backup from this rollout:
|
||||||
|
`/home/devops/shared-notification-private/backup-20261024T094457816933Z`.
|
||||||
|
It has only the **first three migrations**, predates the owner's production push
|
||||||
|
channel, and is not a backup of today's six-migration state. Take a fresh backup before
|
||||||
|
any recovery. A local dump does not protect against loss of this host or disk.
|
||||||
|
No offsite copy or full disaster restore has been proven.
|
||||||
|
|
||||||
|
Verify a selected backup without restoring it (set the exact existing folder):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BACKUP_DIR=/home/devops/shared-notification-private/backup-20261024T094457816933Z
|
||||||
|
export BACKUP_DIR
|
||||||
|
python3 - <<'PY'
|
||||||
|
import hashlib,json,os,pathlib
|
||||||
|
p=pathlib.Path(os.environ['BACKUP_DIR'])
|
||||||
|
m=json.loads((p/'manifest.json').read_text()); data=(p/'database.dump').read_bytes()
|
||||||
|
assert len(data)==m['bytes'] and hashlib.sha256(data).hexdigest()==m['sha256']
|
||||||
|
assert data.startswith(b'PGDMP')
|
||||||
|
print('Checksum and length verified; migration history:', m['history'])
|
||||||
|
PY
|
||||||
|
kn exec -i deployment/postgres -- pg_restore --list < "$BACKUP_DIR/database.dump" >/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
Protect an additional recovery export with restrictive permissions. The following
|
||||||
|
commands contain secrets in their **files**, never print or commit them. They are
|
||||||
|
operator recovery instructions, not proof that an offsite backup exists:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
umask 077
|
||||||
|
RECOVERY_DIR=/home/devops/shared-notification-private/recovery-$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
mkdir "$RECOVERY_DIR"
|
||||||
|
kn get secret -o json > "$RECOVERY_DIR/secrets.json"
|
||||||
|
kn get deployment,service,configmap,networkpolicy,resourcequota,limitrange,pvc -o json > "$RECOVERY_DIR/namespace-resources.json"
|
||||||
|
k get pv shared-notification-postgres shared-notification-rabbitmq -o json > "$RECOVERY_DIR/pvs.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
These are inventories with server-assigned fields, not manifests to blindly apply
|
||||||
|
to a new cluster. Keep protected copies of the package config, kubeconfig and private
|
||||||
|
credential files with your backup system. RabbitMQ definitions do not back up queued
|
||||||
|
messages. A broker volume backup requires a coordinated stopped broker and publisher
|
||||||
|
maintenance window; no live-volume tar or restore drill was performed here.
|
||||||
|
|
||||||
|
## 7. Recover/redeploy this release on retained infrastructure
|
||||||
|
|
||||||
|
Prerequisites: correct disk/PVCs, original Secrets, PostgreSQL and RabbitMQ Ready,
|
||||||
|
no concurrent deployment, verified fresh backup, exact pinned image variables from
|
||||||
|
section 3. Web/Worker remain stopped from section 6.
|
||||||
|
|
||||||
|
If history already contains exactly the six entries above, skip migration. If it is
|
||||||
|
exactly the original three-entry prefix, use the following maintained recipe from
|
||||||
|
the package directory. It creates the same bounded migrator Job used for the rollout,
|
||||||
|
references the existing owner Secret, and replaces only the completed owned migrator
|
||||||
|
Job to fit the quota. Other histories require inspection; do not guess or edit EF history.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export MIGRATOR_IMAGE
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os,types
|
||||||
|
import notification as n
|
||||||
|
c=n.Cluster(types.SimpleNamespace(kubectl='/snap/microk8s/current/kubectl',kubeconfig='/home/devops/.kube/cluster-admin.conf'))
|
||||||
|
prefix=['20260501214629_InitialCreate','20260502154249_PendingChanges','20261017072410_EmailTemplateLocalizations']
|
||||||
|
target=prefix+['20261023223753_AddPushDeliveries','20261024073202_LinkEmailChannelsToServices','20261024080457_AddPushChannels']
|
||||||
|
with n.lock('/home/devops/shared-notification-private'):
|
||||||
|
assert all(c.get('deployment',x)['spec']['replicas']==0 for x in ('web','worker'))
|
||||||
|
assert c.history()==prefix, 'Unexpected history; stop and inspect'
|
||||||
|
old=c.get('job','notification-migrator')
|
||||||
|
assert old and old['metadata']['labels']['app.kubernetes.io/managed-by']==n.MANAGED and old['status'].get('succeeded')==1
|
||||||
|
job=n.job('migrator',os.environ['MIGRATOR_IMAGE'],environment=[n.env('DOTNET_ENVIRONMENT','Production'),n.env('App__ConnectionString',secret='notification-private',key='ownerConnectionString')])
|
||||||
|
n.run_job(c,job)
|
||||||
|
assert c.history()==target
|
||||||
|
print('All six migrations verified; apply runtime grants before starting writers.')
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply and verify the grants in section 5. Inspect migrated channel ownership without
|
||||||
|
reading credentials:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn exec deployment/postgres -- psql -U notification_owner -d notification_service -At -c 'SELECT s.name,count(*) FROM email_channels c JOIN services s ON s.id=c.service_id GROUP BY s.name ORDER BY s.name'
|
||||||
|
```
|
||||||
|
|
||||||
|
Set only application images, retaining Secrets, volumes, selectors and resources:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn set image deployment/web web="$WEB_IMAGE"
|
||||||
|
kn set image deployment/worker worker="$WORKER_IMAGE"
|
||||||
|
kn scale deployment/web --replicas=1
|
||||||
|
kn rollout status deployment/web --timeout=240s
|
||||||
|
kn scale deployment/worker --replicas=1
|
||||||
|
kn rollout status deployment/worker --timeout=240s
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify actual pod imageIDs, six migrations, grants, consumers, UI and OAuth.
|
||||||
|
If migration fails, keep writers stopped while inspecting the failed Job and database
|
||||||
|
history. If rollout fails, inspect events/logs before changing anything else. Never
|
||||||
|
leave replicas at zero without reporting the specific failure and recovery action.
|
||||||
|
|
||||||
|
## 8. Rollback and destructive database restore
|
||||||
|
|
||||||
|
An image-only rollback is allowed only when the target image supports the **current**
|
||||||
|
schema. `kubectl rollout undo` can select the old pre-ServiceId binary and is unsafe
|
||||||
|
after this migration. Prefer reapplying the known six-migration image pair above for
|
||||||
|
an application/configuration regression. Do not roll back the database merely because
|
||||||
|
a port-forward or OAuth connection fails.
|
||||||
|
|
||||||
|
**DESTRUCTIVE, NOT EXECUTED ON PRODUCTION:** the following replaces the database with
|
||||||
|
the explicitly selected backup. It loses writes after that backup and can conflict
|
||||||
|
with retained broker messages. Obtain a maintenance window, pause publishers, stop
|
||||||
|
Web/Worker, verify a fresh safety backup, and select the intended recovery point first.
|
||||||
|
PostgreSQL must be healthy, original roles/passwords retained, and the backup must
|
||||||
|
pass section 6. Do not run this block for validation or as a routine redeploy.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Linux SSH session; BACKUP_DIR must identify the deliberately selected verified dump.
|
||||||
|
test -s "$BACKUP_DIR/database.dump" || exit 1
|
||||||
|
kn exec deployment/postgres -- dropdb -U notification_owner --force notification_service
|
||||||
|
kn exec deployment/postgres -- createdb -U notification_owner -O notification_owner notification_service
|
||||||
|
kn exec -i deployment/postgres -- pg_restore -U notification_owner -d notification_service --exit-on-error --single-transaction < "$BACKUP_DIR/database.dump"
|
||||||
|
```
|
||||||
|
|
||||||
|
`dropdb --force` terminates database connections. If restoration fails, leave writers
|
||||||
|
stopped, retain the failed database for diagnosis and do not replay arbitrary messages.
|
||||||
|
Inspect restored migration history. With a three-migration backup, follow section 7
|
||||||
|
to migrate forward and reapply grants before starting the six-migration application.
|
||||||
|
With six migrations, verify grants and restart the pinned pair. Inspect broker queues
|
||||||
|
and externally recorded outcomes before resuming publishers: database restore does
|
||||||
|
not rewind acknowledgements or external email/push delivery.
|
||||||
|
|
||||||
|
For total node/PV loss, this document does **not** claim a tested bare-metal restore.
|
||||||
|
Recover the original mounted disk and private files first where possible. Rebuilding a
|
||||||
|
cluster from exports requires reviewing namespace ownership, PV binding and node paths,
|
||||||
|
Secret consistency, and sanitized manifests. Do not invoke the stale bootstrap package
|
||||||
|
blindly. A separately rehearsed full disaster-recovery procedure remains a gap.
|
||||||
|
|
||||||
|
## 9. RabbitMQ and Worker checks
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn exec deployment/rabbitmq -- rabbitmq-diagnostics -q check_running
|
||||||
|
kn exec deployment/rabbitmq -- rabbitmqctl list_queues -p /notification name consumers messages_ready messages_unacknowledged
|
||||||
|
kn exec deployment/rabbitmq -- rabbitmqctl list_permissions -p /notification
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected after recovery: `notification.send-email` has exactly one consumer;
|
||||||
|
`invemory.production.notification-results` has one consumer from Invemory. Zero queued
|
||||||
|
messages was the verified baseline, not a requirement to purge legitimate new work.
|
||||||
|
Development/staging result queues can have zero consumers. Broker nodename is fixed
|
||||||
|
`rabbit@rabbitmq`; do not change it against the retained data directory.
|
||||||
|
|
||||||
|
Worker read ACL covers `notification.send-email` only. Its configure/write ACLs cover
|
||||||
|
the existing email result routes, including production/staging/development. The
|
||||||
|
production publisher is `invemory_production`, staging is `invemory_staging`; an
|
||||||
|
additional development account exists. Preserve all current accounts and definitions;
|
||||||
|
do not replay an older definitions Secret to fix a consumer. Imported definitions
|
||||||
|
hold credentials, not a complete live message backup. The shared default exchange
|
||||||
|
does not isolate application `ServiceName` values between publishers.
|
||||||
|
|
||||||
|
## 10. Google OAuth/FCM egress and timeout diagnosis
|
||||||
|
|
||||||
|
On 2026-10-24 DNS resolved both Google endpoints, but all four Web/Worker TLS probes
|
||||||
|
timed out. Base default-deny allowed only internal services and SMTP 587. Applying
|
||||||
|
`google-api-network.json` fixed all four TLS handshakes immediately without a restart;
|
||||||
|
the real production UI then returned **OAuth access token obtained**.
|
||||||
|
|
||||||
|
The policy selects only managed Web/Worker pods in `shared-notification`, IPv4 TCP
|
||||||
|
443 to Google's published API/service ranges. It grants no ingress or arbitrary
|
||||||
|
internet access. Existing DNS policy allows TCP/UDP 53 only to kube-system CoreDNS.
|
||||||
|
Calico OSS standard NetworkPolicy does not provide an FQDN allowlist. Google documents
|
||||||
|
that individual APIs have no stable dedicated IP list: this snapshot uses `goog.json`
|
||||||
|
minus `cloud.json` (297 CIDRs at verification), excluding customer Cloud ranges but
|
||||||
|
still including other Google-operated services. This is an IP-level provider boundary,
|
||||||
|
not a hostname-level boundary. [Google's range method](https://docs.cloud.google.com/vpc/docs/configure-private-google-access#ip-addr-defaults).
|
||||||
|
|
||||||
|
Read-only probes from each actual runtime pod (7-second bound, no credentials):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for component in web worker; do
|
||||||
|
for endpoint in oauth2.googleapis.com fcm.googleapis.com; do
|
||||||
|
printf '%s %s\n' "$component" "$endpoint"
|
||||||
|
kn exec deployment/"$component" -- getent ahostsv4 "$endpoint"
|
||||||
|
kn exec deployment/"$component" -- sh -c 'timeout 7 openssl s_client -connect "$1:443" -servername "$1" -verify_hostname "$1" -verify_return_error -brief </dev/null' sh "$endpoint"
|
||||||
|
done
|
||||||
|
done
|
||||||
|
kn get networkpolicy
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected TLS connection and certificate verification OK. Exit 124 means timeout;
|
||||||
|
name-resolution errors indicate DNS rather than Firebase credentials. Do not disable
|
||||||
|
certificate verification. A successful TLS check alone does not prove OAuth succeeds.
|
||||||
|
Use the saved channel's **Check Firebase authorization** action afterwards; no key needs
|
||||||
|
to be reentered when its project is unchanged. Its current application timeout is 30s.
|
||||||
|
|
||||||
|
Refresh the range snapshot when Google updates its ranges; no automatic updater is
|
||||||
|
installed. The renderer downloads only public feeds, generates a candidate, and never
|
||||||
|
applies it. Keep the reviewed snapshot in Git. On Windows PowerShell, from this repo:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python docker/kubernetes/shared-notification/refresh-google-api-network.py --output C:/projects/ItemTracker/tmp/IT-1074-push/google-api-network.candidate.json
|
||||||
|
git diff --no-index -- docker/kubernetes/shared-notification/google-api-network.json C:/projects/ItemTracker/tmp/IT-1074-push/google-api-network.candidate.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Review source dates and CIDR changes. A diff exit code of 1 means files differ. Confirm
|
||||||
|
the current DNS answers are covered and all addresses remain public. Copy the reviewed
|
||||||
|
candidate into the repository manifest, then transfer only that nonsecret file:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item -LiteralPath C:/projects/ItemTracker/tmp/IT-1074-push/google-api-network.candidate.json -Destination docker/kubernetes/shared-notification/google-api-network.json
|
||||||
|
scp -i C:/Users/agryn/.ssh/id_ed25519_devops docker/kubernetes/shared-notification/google-api-network.json devops@192.168.2.44:/home/devops/notification-package/google-api-network.json
|
||||||
|
```
|
||||||
|
|
||||||
|
On the server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn apply --dry-run=server -f /home/devops/notification-package/google-api-network.json
|
||||||
|
kn apply -f /home/devops/notification-package/google-api-network.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat all four TLS probes and OAuth. Keep the prior reviewed manifest for policy
|
||||||
|
rollback. Do not replace a failed DNS answer with a permanent single /32 rule: Google
|
||||||
|
addresses rotate. Apply the production SMTP/broker overlay separately only when those
|
||||||
|
policies need reconciliation; Google recovery does not require modifying other namespaces.
|
||||||
|
|
||||||
|
## 11. Owner's port-forward: exact address and restart
|
||||||
|
|
||||||
|
Run in a dedicated **Windows PowerShell** terminal. Leave the process running:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -tt -o StrictHostKeyChecking=yes -o HostKeyAlgorithms=ssh-ed25519 -o ConnectTimeout=10 -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=2 -i C:/Users/agryn/.ssh/id_ed25519_devops -L 127.0.0.1:15200:127.0.0.1:15200 devops@192.168.2.44 'exec /snap/microk8s/current/kubectl --kubeconfig=/home/devops/.kube/cluster-admin.conf --namespace=shared-notification --request-timeout=15s port-forward --address=127.0.0.1 service/web 15200:8080'
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:15200/admin/services`. A service port-forward binds to a selected
|
||||||
|
pod; replacing that pod can break the existing tunnel even when its successor is Ready.
|
||||||
|
After rollout, verify **this exact owner URL**, not only a temporary alternate port:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-WebRequest -Uri http://localhost:15200/admin/services -UseBasicParsing -TimeoutSec 10 | Select-Object StatusCode
|
||||||
|
Get-NetTCPConnection -LocalPort 15200 -ErrorAction SilentlyContinue | Select-Object LocalAddress,State,OwningProcess
|
||||||
|
Get-CimInstance Win32_Process -Filter "Name = 'ssh.exe'" | Select-Object ProcessId,CommandLine
|
||||||
|
```
|
||||||
|
|
||||||
|
If broken, stop only the identified 15200 tunnel with Ctrl+C in its own terminal and
|
||||||
|
rerun the exact command. Do not kill all SSH processes or a working tunnel belonging
|
||||||
|
to another task. If the remote port is occupied, inspect its process/owner before
|
||||||
|
stopping anything. For a background helper use `Start-Process -WindowStyle Hidden`,
|
||||||
|
retain its PID and output paths; never leave an untracked visible helper window.
|
||||||
|
|
||||||
|
For optional broker or Mailpit inspection, use separate loopback tunnels with the same
|
||||||
|
SSH options and matching local/remote port pairs: RabbitMQ `15672:15672` forwarding
|
||||||
|
`service/rabbitmq`, or Mailpit `18025:8025` forwarding `service/mailpit`. Do not bind
|
||||||
|
administration to `0.0.0.0` or expose it publicly.
|
||||||
|
|
||||||
|
## 12. Incident checklist and evidence
|
||||||
|
|
||||||
|
Read logs locally; messages may contain recipient or provider details. Do not paste raw
|
||||||
|
logs into public places. Narrow by time and component:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kn get events --sort-by=.metadata.creationTimestamp
|
||||||
|
kn logs deployment/web --since=10m --tail=100
|
||||||
|
kn logs deployment/worker --since=10m --tail=100
|
||||||
|
kn logs job/notification-migrator --tail=100
|
||||||
|
kn get configmap runtime-grants -o yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
| Symptom | Next check |
|
||||||
|
| --- | --- |
|
||||||
|
| UI connection refused, pods Ready | Exact localhost:15200 tunnel; restart only stale forward |
|
||||||
|
| OAuth times out after 30s | DNS and TLS from Web; Google policy and current feed coverage |
|
||||||
|
| OAuth rejected immediately | Correct Firebase project/service account, key validity; no credential logging |
|
||||||
|
| Permission denied for push table | Quoted `"PushDeliveries"`, runtime grants and connection role |
|
||||||
|
| Web stays unready | Events, cold-start CPU, database query, schema/history; do not remove readiness |
|
||||||
|
| Worker Ready but no deliveries | RabbitMQ consumer count/ACLs and runtime logs; process readiness is insufficient |
|
||||||
|
| ImagePullBackOff | Exact digest, registry reachability, existing pull Secret; do not regenerate credentials |
|
||||||
|
| Pod storage guard failure | Real filesystem UUID/mount/marker before any restart or data-directory repair |
|
||||||
|
|
||||||
|
The platform may log a missing optional `libgssapi_krb5.so.2`; in this rollout Web still
|
||||||
|
became ready and used its password connection. Diagnose the actual failing operation
|
||||||
|
instead of assuming that warning caused an OAuth network timeout.
|
||||||
|
|
||||||
|
## 13. CI distinction and verification limits
|
||||||
|
|
||||||
|
TeamCity `HrynCo_NotificationService_BuildAndPush` build 141783 produced the verified
|
||||||
|
images. `HrynCo_NotificationService_DeployProduction` build 141784 deployed the **old
|
||||||
|
Docker Compose target on mini-pc-1**, not this Kubernetes namespace. A green job there
|
||||||
|
is not Kubernetes rollout or owner UI proof. Kubernetes was manually deployed using
|
||||||
|
the retained production objects and pinned digests described here.
|
||||||
|
|
||||||
|
Contracts publishing is a separate NuGet operation. Build 141789 was blocked by
|
||||||
|
incompatible agent requirements (`teamcity.agent.name=ItemTrackerDotNet10` and
|
||||||
|
`system.agent.name=mini-pc-1`). Do not publish Contracts manually or alter CI as part of
|
||||||
|
runtime recovery. Some current checks also return success despite missing dotnet or
|
||||||
|
unavailable Docker test prerequisites; rely on actual test output, not status alone.
|
||||||
|
|
||||||
|
Verified: pinned running images, six migration entries, runtime table privileges,
|
||||||
|
preserved Secrets/PVC identities and existing email contents, custom-format backup
|
||||||
|
checksum/listing, Web/Worker Ready with zero restarts, email/result consumers, owner
|
||||||
|
URL UI, four OAuth/FCM TLS paths, and successful real production OAuth check. No push
|
||||||
|
was sent. Destructive database restore, full host loss recovery, offsite backup,
|
||||||
|
RabbitMQ message restore and end-to-end production push delivery were **not tested**.
|
||||||
|
Publishing and merging these documentation/egress follow-ups require a separate
|
||||||
|
delivery request; a local commit does not authorize those operations. IT-1074 remains open.
|
||||||
@@ -1,14 +1,5 @@
|
|||||||
# Supported incoming message types
|
# 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:
|
The Worker uses `SupportedMessageType` for its two incoming commands:
|
||||||
|
|
||||||
| AMQP MessageType (unchanged) | Public enum | Consumer queue |
|
| AMQP MessageType (unchanged) | Public enum | Consumer queue |
|
||||||
@@ -33,17 +24,3 @@ correlation validation remains unchanged.
|
|||||||
The Push Channels change removes the draft Firebase project/access-token fields from
|
The Push Channels change removes the draft Firebase project/access-token fields from
|
||||||
SendPushMessageData. The enum and AMQP header stay unchanged; see
|
SendPushMessageData. The enum and AMQP header stay unchanged; see
|
||||||
[push channels](android-push.md) for producer compatibility and rollout order.
|
[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