feat(push): add firebase channels and service-linked email

Deliver Notification Service push contracts, provider and administration with verified migrations and tests.

Ref: IT-1074
This commit was merged in pull request #28.
This commit is contained in:
2026-10-24 12:32:22 +03:00
105 changed files with 3838 additions and 147 deletions
+1
View File
@@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Entity Framework Core -->
<PackageVersion Include="FirebaseAdmin" Version="3.7.0" />
<PackageVersion Include="HrynCo.DAL.Abstract" Version="1.0.11" />
<PackageVersion Include="HrynCo.DAL.EF" Version="1.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
@@ -2,6 +2,10 @@ namespace HrynCo.NotificationService.Contracts.Messages;
public record NotificationResultData
{
public Guid? PushRegistrationId { get; init; }
public Guid? PushBindingId { get; init; }
public string? PushTokenHash { get; init; }
public bool InvalidPushRegistration { get; init; }
public required string ServiceName { get; init; }
public required string RecipientEmail { get; init; }
public required string TemplateKey { get; init; }
@@ -0,0 +1,9 @@
namespace HrynCo.NotificationService.Contracts.Messages;
using Hrynco.RabbitMq;
public sealed record SendPushMessage : IRabbitMqMessage<SendPushMessageData>
{
public CorrelationContext CorrelationContext { get; set; } = default!;
public SendPushMessageData Data { get; set; } = default!;
}
@@ -0,0 +1,16 @@
namespace HrynCo.NotificationService.Contracts.Messages;
public sealed record SendPushMessageData
{
public required Guid RegistrationId { get; init; }
public required Guid BindingId { get; init; }
public required Guid UserId { get; init; }
public required string ServiceName { get; init; }
public required string Title { get; init; }
public required string Body { get; init; }
public string Route { get; init; } = "/home";
public int Version { get; init; } = 1;
public required DateTimeOffset ExpiresAt { get; init; }
public string? Token { get; init; }
public string? TokenHash { get; init; }
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.Contracts.Messages;
public enum SupportedMessageType
{
SendEmailV1,
SendPushV1
}
@@ -4,7 +4,8 @@ namespace HrynCo.NotificationService.DAL.Abstract.Providers;
public class EmailChannel : Entity
{
public required string ServiceName { get; set; }
public required Guid ServiceId { get; set; }
public string ServiceName { get; set; } = string.Empty;
public int Priority { get; set; }
public EmailChannelType EmailChannelType { get; set; }
public required EmailChannelSettings Settings { get; set; }
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public interface IPushDeliveryRepository
{
Task<PushDeliveryOutcome> DeliverOnceAsync(Guid messageId, string serviceName,
Func<CancellationToken, Task<PushDeliveryOutcome>> deliver, CancellationToken cancellationToken);
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public sealed record PushDeliveryOutcome(string? Error, bool InvalidRegistration = false);
@@ -0,0 +1,44 @@
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Parse only service-account fields; never honor caller-supplied OAuth endpoints.
public sealed class FirebaseServiceAccountCredentials
{
public required string ProjectId { get; init; }
public required string ClientEmail { get; init; }
public required string PrivateKey { get; init; }
public static bool IsValidProjectId(string projectId) => Regex.IsMatch(projectId,
"^[a-z][a-z0-9-]{4,28}[a-z0-9]$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
public static FirebaseServiceAccountCredentials Parse(string json, string projectId)
{
if (!IsValidProjectId(projectId) || json.Length > 32768)
throw new FormatException("Invalid Firebase configuration.");
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (Read(root, "type") != "service_account" || Read(root, "project_id") != projectId)
throw new FormatException("The service account must belong to the selected Firebase project.");
var email = Read(root, "client_email");
if (!MailAddress.TryCreate(email, out var address) || address.Address != email)
throw new FormatException("Invalid service account email.");
var key = Read(root, "private_key");
using var rsa = RSA.Create();
rsa.ImportFromPem(key);
// Exporting private parameters also rejects a public-key-only PEM.
rsa.ExportParameters(true);
return new FirebaseServiceAccountCredentials { ProjectId = projectId, ClientEmail = email, PrivateKey = key };
}
private static string Read(JsonElement root, string name)
{
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var value)
|| value.ValueKind != JsonValueKind.String)
throw new FormatException("Missing service account fields.");
return value.GetString() ?? string.Empty;
}
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public interface IPushChannelRepository
{
Task<IReadOnlyList<PushChannel>> GetAllAsync(CancellationToken ct = default);
Task<PushChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<PushChannel?> GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default);
Task<string?> GetCredentialsAsync(Guid id, CancellationToken ct = default);
Task SaveAsync(PushChannel channel, string credentialsJson, bool isNew, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Safe administration/read model: credentials are fetched separately by server code.
public sealed class PushChannel : Entity
{
public required Guid ServiceId { get; set; }
public string ServiceName { get; set; } = string.Empty;
public required string FirebaseProjectId { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,6 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class PushChannelWriteException(string message, bool isConflict = true) : Exception(message)
{
public bool IsConflict { get; } = isConflict;
}
@@ -5,7 +5,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IEmailChannelRepository
{
Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default);
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(Guid serviceId, CancellationToken ct = default);
Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default);
Task AddAsync(EmailChannel channel, CancellationToken ct = default);
@@ -13,12 +13,10 @@ internal class EmailChannelEntityConfiguration : IEntityTypeConfiguration<EmailC
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceName)
.HasColumnName("service_name")
.IsRequired()
.HasMaxLength(100);
builder.HasIndex(x => new { x.ServiceName, x.Priority });
builder.Property(x => x.ServiceId).HasColumnName("service_id").IsRequired();
builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.ServiceId, x.Priority });
builder.Property(x => x.Priority).HasColumnName("priority");
@@ -0,0 +1,23 @@
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal sealed class PushChannelEntityConfiguration : IEntityTypeConfiguration<PushChannelEntity>
{
public void Configure(EntityTypeBuilder<PushChannelEntity> builder)
{
builder.ToTable("push_channels");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceId).HasColumnName("service_id");
builder.HasIndex(x => x.ServiceId).IsUnique();
builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.FirebaseProjectId).HasColumnName("firebase_project_id").HasMaxLength(30).IsRequired();
builder.Property(x => x.CredentialsJson).HasColumnName("credentials").HasColumnType("jsonb").IsRequired();
builder.Property(x => x.IsActive).HasColumnName("is_active");
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
}
}
@@ -0,0 +1,17 @@
namespace HrynCo.NotificationService.DAL.EF.Configurations;
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public sealed class PushDeliveryEntityConfiguration : IEntityTypeConfiguration<PushDeliveryEntity>
{
public void Configure(EntityTypeBuilder<PushDeliveryEntity> builder)
{
builder.ToTable("PushDeliveries");
builder.HasKey(x => new { x.ServiceName, x.MessageId });
builder.Property(x => x.ServiceName).HasMaxLength(128);
builder.Property(x => x.Error).HasMaxLength(256);
builder.HasIndex(x => x.CompletedAt);
}
}
@@ -5,7 +5,8 @@ namespace HrynCo.NotificationService.DAL.EF.Entities;
internal class EmailChannelEntity : Entity
{
public required string ServiceName { get; set; }
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
public int Priority { get; set; }
public EmailChannelType EmailChannelType { get; set; }
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushChannelEntity : Entity
{
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
public required string FirebaseProjectId { get; set; }
public required string CredentialsJson { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.DAL.EF.Entities;
public sealed class PushDeliveryEntity
{
public Guid MessageId { get; set; }
public string ServiceName { get; set; } = "";
public string? Error { get; set; }
public bool InvalidRegistration { get; set; }
public DateTimeOffset CompletedAt { get; set; }
}
@@ -0,0 +1,402 @@
// <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("20261023223753_AddPushDeliveries")]
partial class AddPushDeliveries
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasColumnName("daily_limit");
b.Property<int>("EmailChannelType")
.HasColumnType("integer")
.HasColumnName("provider_type");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<int?>("MonthlyLimit")
.HasColumnType("integer")
.HasColumnName("monthly_limit");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.Property<int>("WarnThresholdPercent")
.HasColumnType("integer")
.HasColumnName("warn_threshold_percent");
b.HasKey("Id");
b.HasIndex("ServiceName", "Priority");
b.ToTable("email_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid")
.HasColumnName("provider_id");
b.Property<int>("SentCount")
.HasColumnType("integer")
.HasColumnName("sent_count");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ProviderId", "Date")
.IsUnique();
b.ToTable("email_channel_usage", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("email_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("html_body");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("TextBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text_body");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("email_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.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.ServiceEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("services", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Code")
.IsUnique();
b.ToTable("service_languages", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddPushDeliveries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PushDeliveries",
columns: table => new
{
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
ServiceName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Error = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
InvalidRegistration = table.Column<bool>(type: "boolean", nullable: false),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PushDeliveries", x => new { x.ServiceName, x.MessageId });
});
migrationBuilder.CreateIndex(
name: "IX_PushDeliveries_CompletedAt",
table: "PushDeliveries",
column: "CompletedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PushDeliveries");
}
}
}
@@ -0,0 +1,411 @@
// <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("20261024073202_LinkEmailChannelsToServices")]
partial class LinkEmailChannelsToServices
{
/// <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.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.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.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.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations;
public partial class LinkEmailChannelsToServices : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// EF runs this whole migration in one transaction. Lock the source rows so a
// concurrent rename or channel write cannot invalidate the verified mapping.
migrationBuilder.Sql("""
LOCK TABLE services IN SHARE ROW EXCLUSIVE MODE;
LOCK TABLE email_channels IN ACCESS EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM email_channels c
WHERE (SELECT count(*) FROM services s
WHERE s.name = c.service_name
OR (c.service_name = 'StoreMate-Prod' AND s.name = 'Invemory-Prod')) <> 1
) THEN
RAISE EXCEPTION 'Email channel migration stopped: every legacy service name must have exactly one existing service (including the approved StoreMate-Prod -> Invemory-Prod alias). Resolve missing or ambiguous names before retrying.';
END IF;
END $$;
ALTER TABLE email_channels ADD COLUMN service_id uuid;
UPDATE email_channels c SET service_id = s.id
FROM services s
WHERE s.name = c.service_name
OR (c.service_name = 'StoreMate-Prod' AND s.name = 'Invemory-Prod');
ALTER TABLE email_channels ALTER COLUMN service_id SET NOT NULL;
""");
migrationBuilder.DropIndex("IX_email_channels_service_name_priority", "email_channels");
migrationBuilder.DropColumn("service_name", "email_channels");
migrationBuilder.CreateIndex("IX_email_channels_service_id_priority", "email_channels", new[] { "service_id", "priority" });
migrationBuilder.AddForeignKey("FK_email_channels_services_service_id", "email_channels", "service_id",
"services", principalColumn: "id", onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Restore current service names; historical names are intentionally not retained.
migrationBuilder.Sql("""
ALTER TABLE email_channels ADD COLUMN service_name character varying(100);
UPDATE email_channels c SET service_name = s.name FROM services s WHERE s.id = c.service_id;
ALTER TABLE email_channels ALTER COLUMN service_name SET NOT NULL;
""");
migrationBuilder.DropForeignKey("FK_email_channels_services_service_id", "email_channels");
migrationBuilder.DropIndex("IX_email_channels_service_id_priority", "email_channels");
migrationBuilder.DropColumn("service_id", "email_channels");
migrationBuilder.CreateIndex("IX_email_channels_service_name_priority", "email_channels", new[] { "service_name", "priority" });
}
}
@@ -0,0 +1,464 @@
// <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("20261024080457_AddPushChannels")]
partial class AddPushChannels
{
/// <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.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.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.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.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.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddPushChannels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "push_channels",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
firebase_project_id = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
credentials = table.Column<string>(type: "jsonb", nullable: false),
is_active = table.Column<bool>(type: "boolean", 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_channels", x => x.id);
table.ForeignKey(
name: "FK_push_channels_services_service_id",
column: x => x.service_id,
principalTable: "services",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_push_channels_service_id",
table: "push_channels",
column: "service_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "push_channels");
}
}
}
@@ -53,11 +53,9 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<string>("SettingsJson")
.IsRequired()
@@ -74,7 +72,7 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.HasKey("Id");
b.HasIndex("ServiceName", "Priority");
b.HasIndex("ServiceId", "Priority");
b.ToTable("email_channels", (string)null);
});
@@ -206,6 +204,74 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
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.ServiceEntity", b =>
{
b.Property<Guid>("Id")
@@ -273,6 +339,17 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.ToTable("service_languages", (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)
@@ -342,6 +419,17 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
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.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
@@ -11,6 +11,7 @@ public class NotificationDbContext : DbContext
}
internal DbSet<ServiceLanguageEntity> ServiceLanguages => Set<ServiceLanguageEntity>();
internal DbSet<PushChannelEntity> PushChannels => Set<PushChannelEntity>();
internal DbSet<ServiceEntity> Services => Set<ServiceEntity>();
internal DbSet<EmailTemplateDefinitionEntity> TemplateDefinitions => Set<EmailTemplateDefinitionEntity>();
internal DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>();
@@ -8,7 +8,8 @@ internal sealed class NotificationDbContextFactory : IDesignTimeDbContextFactory
public NotificationDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<NotificationDbContext>()
.UseNpgsql("Host=localhost;Port=5432;Database=notification_service;Username=postgres;Password=postgres")
.UseNpgsql(Environment.GetEnvironmentVariable("App__ConnectionString")
?? "Host=localhost;Port=5432;Database=notification_service;Username=postgres;Password=postgres")
.Options;
return new NotificationDbContext(options);
@@ -15,14 +15,14 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default)
{
var entities = await EfRepository.Get().ToListAsync(ct);
var entities = await EfRepository.Get().AsNoTracking().Include(x => x.Service).ToListAsync(ct);
return entities.Select(MapToDomain).ToList();
}
public async Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default)
public async Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(Guid serviceId, CancellationToken ct = default)
{
var entities = await EfRepository.Get(x => x.ServiceName == serviceName)
var entities = await EfRepository.Get(x => x.ServiceId == serviceId).Include(x => x.Service)
.OrderBy(x => x.Priority)
.ToListAsync(ct);
@@ -32,8 +32,8 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(
DateOnly today, CancellationToken ct = default)
{
var rows = await EfRepository.Get()
.OrderBy(c => c.ServiceName)
var rows = await EfRepository.Get().AsNoTracking().Include(c => c.Service)
.OrderBy(c => c.Service.Name)
.ThenBy(c => c.Priority)
.Select(c => new
{
@@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
EmailChannelEntity? entity = await EfRepository.Get().AsNoTracking().SingleOrDefaultAsync(c => c.Id == id, ct);
EmailChannelEntity? entity = await EfRepository.Get().AsNoTracking().Include(c => c.Service).SingleOrDefaultAsync(c => c.Id == id, ct);
return entity is null ? null : MapToDomain(entity);
}
@@ -79,7 +79,8 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
return new EmailChannel
{
Id = e.Id,
ServiceName = e.ServiceName,
ServiceId = e.ServiceId,
ServiceName = e.Service.Name,
Priority = e.Priority,
EmailChannelType = e.EmailChannelType,
Settings = DeserializeSettings(e.EmailChannelType, e.SettingsJson),
@@ -97,7 +98,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
return new EmailChannelEntity
{
Id = p.Id,
ServiceName = p.ServiceName,
ServiceId = p.ServiceId,
Priority = p.Priority,
EmailChannelType = p.EmailChannelType,
SettingsJson = JsonSerializer.Serialize(p.Settings, p.Settings.GetType()),
@@ -38,8 +38,7 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return;
if (service.Revision != revision)
throw new EmailTemplateWriteException("The service revision changed. Read it again before deleting.");
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct))
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
await EnsureServiceIsEmptyAsync(id, ct);
db.ServiceLanguages.RemoveRange(service.Languages);
db.Services.Remove(service);
removed = true;
@@ -47,6 +46,14 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return removed;
}
private async Task EnsureServiceIsEmptyAsync(Guid id, CancellationToken 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.");
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.");
}
public Task SaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{
Validate(model);
@@ -0,0 +1,60 @@
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories;
internal sealed class PushChannelRepository(NotificationDbContext db, TemplateMutationRunner mutations) : IPushChannelRepository
{
public async Task<IReadOnlyList<PushChannel>> GetAllAsync(CancellationToken ct = default) =>
await ReadModels().OrderBy(c => c.ServiceName).ToListAsync(ct);
public Task<PushChannel?> GetByIdAsync(Guid id, CancellationToken ct = default) =>
ReadModels().SingleOrDefaultAsync(c => c.Id == id, ct);
public Task<PushChannel?> GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default) =>
ReadModels().SingleOrDefaultAsync(c => c.ServiceId == serviceId && c.IsActive, ct);
public Task<string?> GetCredentialsAsync(Guid id, CancellationToken ct = default) =>
db.PushChannels.AsNoTracking().Where(c => c.Id == id).Select(c => c.CredentialsJson).SingleOrDefaultAsync(ct);
public Task SaveAsync(PushChannel channel, string credentialsJson, bool isNew, CancellationToken ct = default) =>
mutations.ExecuteAsync(async () =>
{
await ValidateServiceAsync(channel, ct);
var entity = await LoadForSaveAsync(channel.Id, isNew, ct);
entity.ServiceId = channel.ServiceId;
entity.FirebaseProjectId = channel.FirebaseProjectId;
entity.CredentialsJson = credentialsJson;
entity.IsActive = channel.IsActive;
entity.Updated = isNew ? null : DateTimeOffset.UtcNow;
channel.Id = entity.Id;
}, ct);
private async Task ValidateServiceAsync(PushChannel channel, CancellationToken ct)
{
if (!await db.Services.AnyAsync(s => s.Id == channel.ServiceId, ct))
throw new PushChannelWriteException("Select an existing service.", false);
if (await db.PushChannels.AnyAsync(c => c.ServiceId == channel.ServiceId && c.Id != channel.Id, ct))
throw new PushChannelWriteException("This service already has a push channel. Edit that channel instead.");
}
private async Task<PushChannelEntity> LoadForSaveAsync(Guid id, bool isNew, CancellationToken ct)
{
if (!isNew)
return await db.PushChannels.SingleOrDefaultAsync(c => c.Id == id, ct)
?? throw new PushChannelWriteException("The push channel no longer exists. Reload the list.");
var entity = new PushChannelEntity
{
Id = id, FirebaseProjectId = string.Empty, CredentialsJson = "{}", Created = DateTimeOffset.UtcNow
};
db.PushChannels.Add(entity);
return entity;
}
private IQueryable<PushChannel> ReadModels() => db.PushChannels.AsNoTracking().Select(c => new PushChannel
{
Id = c.Id, ServiceId = c.ServiceId, ServiceName = c.Service.Name,
FirebaseProjectId = c.FirebaseProjectId, IsActive = c.IsActive, Created = c.Created, Updated = c.Updated
});
}
@@ -0,0 +1,36 @@
namespace HrynCo.NotificationService.DAL.EF.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
public sealed class PushDeliveryRepository(NotificationDbContext db) : IPushDeliveryRepository
{
public async Task<PushDeliveryOutcome> DeliverOnceAsync(Guid messageId, string serviceName,
Func<CancellationToken, Task<PushDeliveryOutcome>> deliver, CancellationToken cancellationToken)
{
// Thirty days exceeds the maximum payload lifetime; old replays cannot send expired data.
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
await db.Set<PushDeliveryEntity>().Where(x => x.CompletedAt < cutoff).ExecuteDeleteAsync(cancellationToken);
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
var identity = $"{serviceName}:{messageId:D}";
await db.Database.ExecuteSqlInterpolatedAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({identity}, 1074))", cancellationToken);
var previous = await db.Set<PushDeliveryEntity>().AsNoTracking()
.SingleOrDefaultAsync(x => x.MessageId == messageId && x.ServiceName == serviceName, cancellationToken);
if (previous is not null)
return new PushDeliveryOutcome(previous.Error, previous.InvalidRegistration);
// One bounded provider call while holding this message's lock prevents concurrent sends.
// A crash after FCM accepts still requires client-side message-id deduplication.
var outcome = await deliver(cancellationToken);
db.Add(new PushDeliveryEntity
{
MessageId = messageId, ServiceName = serviceName, Error = outcome.Error,
InvalidRegistration = outcome.InvalidRegistration, CompletedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return outcome;
}
}
@@ -17,6 +17,8 @@ public static class ServiceCollectionExtensions
options.UseNpgsql(connectionString));
services.AddScoped<TemplateMutationRunner>();
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.PushChannels.IPushChannelRepository, PushChannelRepository>();
services.AddScoped<HrynCo.NotificationService.DAL.Abstract.Push.IPushDeliveryRepository, PushDeliveryRepository>();
services.AddScoped<INotificationServiceRepository, NotificationServiceRepository>();
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>();
@@ -0,0 +1,5 @@
@echo off
setlocal DisableDelayedExpansion
set "NOTIFICATION_EF_DIR=%~dp0"
powershell.exe -NoProfile -Command "$ErrorActionPreference = 'Stop'; Set-Location -LiteralPath $env:NOTIFICATION_EF_DIR; $config = Get-Content -LiteralPath '..\HrynCo.NotificationService.Web\appsettings.Development.json' -Raw | ConvertFrom-Json; $connection = New-Object System.Data.Common.DbConnectionStringBuilder; $connection.set_ConnectionString($config.App.ConnectionString); if ([string]$connection.get_Item('Host') -notin @('localhost', '127.0.0.1', '::1') -or [string]::IsNullOrWhiteSpace([string]$connection.get_Item('Database'))) { throw 'Refusing update: Development configuration must specify a loopback host and a database name.' }; $env:App__ConnectionString = $connection.get_ConnectionString(); dotnet ef database update --project 'HrynCo.NotificationService.DAL.EF.csproj' --configuration Release; exit $LASTEXITCODE"
exit /b %ERRORLEVEL%
@@ -32,8 +32,8 @@ internal sealed class DevelopmentDataSeeder
if (string.IsNullOrWhiteSpace(serviceName))
throw new InvalidOperationException("Development seed service name is not configured.");
await SeedMailpitChannelAsync(serviceName, cancellationToken);
await SeedServiceAsync(serviceName, cancellationToken);
var serviceId = await SeedServiceAsync(serviceName, cancellationToken);
await SeedMailpitChannelAsync(serviceId, serviceName, cancellationToken);
foreach (EmailTemplate template in CreateTemplates(serviceName))
{
@@ -55,22 +55,23 @@ internal sealed class DevelopmentDataSeeder
}
}
private async Task SeedServiceAsync(string serviceName, CancellationToken ct)
private async Task<Guid> SeedServiceAsync(string serviceName, CancellationToken ct)
{
var services = await _services.GetAllAsync(ct);
var service = services.SingleOrDefault(s => s.Name == serviceName);
if (service?.Languages.Any(l => l.Code == DefaultLanguageCode) == true)
return;
return service.Id;
var isNew = service is null;
service ??= new NotificationServiceInfo { Name = serviceName };
service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = DefaultLanguageCode }];
await _services.SaveAsync(service, isNew, ct);
return service.Id;
}
private async Task SeedMailpitChannelAsync(string serviceName, CancellationToken cancellationToken)
private async Task SeedMailpitChannelAsync(Guid serviceId, string serviceName, CancellationToken cancellationToken)
{
IReadOnlyList<EmailChannel> existingChannels = await _channels.GetByServiceAsync(
serviceName, cancellationToken);
serviceId, cancellationToken);
if (existingChannels.Count > 0)
{
@@ -82,7 +83,7 @@ internal sealed class DevelopmentDataSeeder
var channel = new EmailChannel
{
ServiceName = serviceName,
ServiceId = serviceId,
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
@@ -13,10 +13,16 @@ public sealed class DevelopmentDataSeederTests
private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>();
private readonly INotificationServiceRepository _services = Substitute.For<INotificationServiceRepository>();
public DevelopmentDataSeederTests()
{
_services.SaveAsync(Arg.Any<NotificationServiceInfo>(), true, Arg.Any<CancellationToken>())
.Returns(call => { call.Arg<NotificationServiceInfo>().Id = Guid.NewGuid(); return Task.CompletedTask; });
}
[Fact]
public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
_channels.GetByServiceAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<EmailChannel>());
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
.Returns((EmailTemplate?)null);
@@ -39,10 +45,10 @@ public sealed class DevelopmentDataSeederTests
[Fact]
public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
_channels.GetByServiceAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns([new EmailChannel
{
ServiceName = "TestService",
ServiceId = Guid.NewGuid(),
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings()
}]);
@@ -75,7 +81,7 @@ public sealed class DevelopmentDataSeederTests
private static bool IsMailpitChannel(EmailChannel channel)
{
return channel.ServiceName == "TestService" &&
return channel.ServiceId != Guid.Empty &&
channel.IsActive &&
channel.Settings is SmtpChannelSettings smtp &&
smtp.Host == "mailpit" &&
@@ -12,7 +12,7 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
CreateContext(),
SendEmailConsumer.SupportedMessageType);
SupportedMessageType.SendEmailV1);
Assert.Null(error);
}
@@ -25,7 +25,7 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
SupportedMessageType.SendEmailV1);
Assert.Equal("AMQP MessageId is required.", error);
}
@@ -41,18 +41,33 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
SupportedMessageType.SendEmailV1);
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error);
}
[Theory]
[InlineData("Notification.SendPush.v1")]
[InlineData("notification.sendemail.v1")]
[InlineData("SendEmailV1")]
[InlineData("0")]
[InlineData("")]
[InlineData(null)]
public void OtherOrMissingWireType_IsRejected(string? wireType)
{
string? error = SendEmailDeliveryValidator.GetValidationError(CreateMessage(),
CreateContext() with { MessageType = wireType }, SupportedMessageType.SendEmailV1);
Assert.Equal($"Unsupported AMQP message type '{wireType ?? "<missing>"}'.", error);
}
private static RabbitMqMessageContext CreateContext() => new()
{
QueueName = SendEmailConsumer.IncomingQueue,
Exchange = string.Empty,
RoutingKey = SendEmailConsumer.IncomingQueue,
MessageId = Guid.NewGuid().ToString("D"),
MessageType = SendEmailConsumer.SupportedMessageType,
MessageType = "Notification.SendEmail.v1",
CorrelationId = "correlation-id",
ContentType = "application/json"
};
@@ -23,12 +23,12 @@ public sealed class SendEmailServiceTests
Password = "not-a-real-secret",
UseSsl = true,
FromEmail = "notifications@example.com",
FromName = "StoreMate"
FromName = "Invemory"
};
var channel = new EmailChannel
{
Id = channelId,
ServiceName = "StoreMate-Prod",
ServiceId = Guid.NewGuid(),
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = settings,
@@ -36,7 +36,7 @@ public sealed class SendEmailServiceTests
};
var template = new EmailTemplate
{
ServiceName = "StoreMate-Prod",
ServiceName = "Invemory-Prod",
Key = "EmailVerification",
LanguageCode = "uk",
Subject = "Verify {{AppName}}",
@@ -46,17 +46,21 @@ public sealed class SendEmailServiceTests
};
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
channels.GetByServiceAsync(channel.ServiceId, Arg.Any<CancellationToken>())
.Returns([channel]);
var services = Substitute.For<INotificationServiceRepository>();
services.GetByNameAsync("Invemory-Prod", Arg.Any<CancellationToken>())
.Returns(new NotificationServiceInfo { Id = channel.ServiceId, Name = "Invemory-Prod" });
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
templates.GetAsync("Invemory-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
.Returns(template);
var renderer = new EmailTemplateRenderingService();
var smtp = new RecordingSmtpEmailSender();
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
var service = new SendEmailService(
channels,
services,
usage,
templates,
renderer,
@@ -71,12 +75,12 @@ public sealed class SendEmailServiceTests
},
Data = new SendEmailMessageData
{
ServiceName = "StoreMate-Prod",
ServiceName = "Invemory-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
Variables = new Dictionary<string, string> { ["AppName"] = "Invemory" }
}
};
@@ -84,7 +88,7 @@ public sealed class SendEmailServiceTests
Assert.Equal(1, smtp.SendCount);
Assert.Same(settings, smtp.Settings);
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
Assert.Equal("Verify Invemory", smtp.Email?.Subject);
Assert.Equal("owner@example.com", smtp.RecipientEmail);
Assert.Equal("Owner", smtp.RecipientName);
await usage.Received(1).IncrementUsageAsync(
@@ -0,0 +1,78 @@
using System.Security.Cryptography;
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.Push;
using NSubstitute;
namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class FcmPushSenderTests
{
[Fact]
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials()
{
var services = Substitute.For<INotificationServiceRepository>();
var channels = Substitute.For<IPushChannelRepository>();
var client = Substitute.For<IFirebasePushClient>();
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory-Renamed" };
var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true };
services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
channels.GetActiveForServiceAsync(service.Id, Arg.Any<CancellationToken>()).Returns(channel);
using var rsa = RSA.Create(2048);
var json = JsonSerializer.Serialize(new
{
type = "service_account", project_id = "test-project", client_email = "test@example.invalid",
private_key = rsa.ExportPkcs8PrivateKeyPem(), token_uri = "https://untrusted.invalid/token"
});
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns(json);
client.SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
Assert.Null(result.Error);
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>());
}
[Theory]
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(true, true)]
public async Task MissingServiceInactiveChannelOrInvalidCredentialsDoNotContactFirebase(bool exists, bool active)
{
var services = Substitute.For<INotificationServiceRepository>();
var channels = Substitute.For<IPushChannelRepository>();
var client = Substitute.For<IFirebasePushClient>();
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" };
if (exists) services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
if (active)
{
var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true };
channels.GetActiveForServiceAsync(service.Id, Arg.Any<CancellationToken>()).Returns(channel);
channels.GetCredentialsAsync(channel.Id, Arg.Any<CancellationToken>()).Returns("secret-must-not-leak");
}
var result = await new FcmPushSender(services, channels, client).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
Assert.NotNull(result.Error);
Assert.DoesNotContain("secret-must-not-leak", result.Error);
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
}
[Fact]
public void PublicPayloadHasNoFirebaseAuthorizationFields()
{
var json = JsonSerializer.Serialize(Data("Invemory"));
Assert.DoesNotContain("Fcm", json);
Assert.DoesNotContain("Credentials", json);
Assert.DoesNotContain("AccessToken", json);
}
private static SendPushMessageData Data(string serviceName) => new()
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ServiceName = serviceName, Title = "Test", Body = "Test", Token = "test-device-token",
TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10)
};
}
@@ -0,0 +1,96 @@
using System.Security.Cryptography;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.Worker.Services.Push;
using NSubstitute;
namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class PushChannelTestServiceTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UsesSavedCredentialsWithoutSavingTokenAndSeparatesAuthorizationFromSend(bool send)
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
var id = Guid.NewGuid();
channels.GetByIdAsync(id, Arg.Any<CancellationToken>()).Returns(new PushChannel
{ Id = id, ServiceId = Guid.NewGuid(), FirebaseProjectId = "test-project", IsActive = false });
channels.GetCredentialsAsync(id, Arg.Any<CancellationToken>()).Returns(Credentials());
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ ChannelId = id, FirebaseProjectId = "test-project", RecipientToken = send ? "test-token" : null }, send, default);
Assert.Null(outcome.Error);
await firebase.Received(send ? 1 : 0).SendTestAsync("test-token", Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default);
}
[Fact]
public async Task UnsavedCredentialsOverrideSavedConfiguration()
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ ChannelId = Guid.NewGuid(), FirebaseProjectId = "test-project", CredentialsJson = Credentials() }, false, default);
Assert.Null(outcome.Error);
await channels.DidNotReceiveWithAnyArgs().GetCredentialsAsync(default, default);
await firebase.Received(1).CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
}
[Theory]
[InlineData("changed-project")]
[InlineData("invalid-json")]
[InlineData("missing-token")]
[InlineData("unknown-channel")]
public async Task InvalidInputNeverContactsFirebase(string scenario)
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
var id = Guid.NewGuid();
if (scenario != "unknown-channel") channels.GetByIdAsync(id, Arg.Any<CancellationToken>()).Returns(new PushChannel
{ Id = id, ServiceId = Guid.NewGuid(), FirebaseProjectId = "test-project" });
channels.GetCredentialsAsync(id, Arg.Any<CancellationToken>()).Returns(Credentials());
var settings = new PushChannelTestSettings
{
ChannelId = id, FirebaseProjectId = scenario == "changed-project" ? "other-project" : "test-project",
CredentialsJson = scenario == "invalid-json" ? "private-secret-do-not-echo" : null
};
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(settings, scenario == "missing-token", default);
Assert.NotNull(outcome.Error);
Assert.DoesNotContain("private-secret-do-not-echo", outcome.Error);
await firebase.DidNotReceiveWithAnyArgs().CheckAuthorizationAsync(default!, default);
await firebase.DidNotReceiveWithAnyArgs().SendTestAsync(default!, default!, default);
}
[Fact]
public async Task RetryableProviderFailureReturnsSafeUnconfirmedResult()
{
var channels = Substitute.For<IPushChannelRepository>();
var firebase = Client();
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PushDeliveryOutcome>(new PushRetryException(TimeSpan.FromSeconds(30))));
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default);
Assert.Contains("acceptance was not confirmed", outcome.Error);
}
private static IFirebasePushClient Client()
{
var firebase = Substitute.For<IFirebasePushClient>();
firebase.CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
firebase.SendTestAsync(Arg.Any<string>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
return firebase;
}
private static string Credentials()
{
using var rsa = RSA.Create(2048);
return JsonSerializer.Serialize(new
{ type = "service_account", project_id = "test-project", client_email = "test@example.invalid", private_key = rsa.ExportPkcs8PrivateKeyPem() });
}
}
@@ -0,0 +1,60 @@
namespace HrynCo.NotificationService.Services.Tests.Push;
using FirebaseAdmin.Messaging;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
using NSubstitute;
public sealed class PushDeliveryTests
{
[Theory]
[InlineData(MessagingErrorCode.Unregistered, true)]
[InlineData(MessagingErrorCode.SenderIdMismatch, false)]
[InlineData(MessagingErrorCode.InvalidArgument, false)]
public void OnlyUnregisteredInvalidatesTheInstallation(MessagingErrorCode code, bool invalid)
{
var result = FirebasePushClient.Classify(code);
Assert.Equal(invalid, result.InvalidRegistration);
Assert.NotNull(result.Error);
}
[Theory]
[InlineData(MessagingErrorCode.Internal)]
[InlineData(MessagingErrorCode.Unavailable)]
[InlineData(MessagingErrorCode.QuotaExceeded)]
public void TransientFailuresPreserveRetryAfter(MessagingErrorCode code)
{
var exception = Assert.Throws<PushRetryException>(() => FirebasePushClient.Classify(code, TimeSpan.FromMinutes(3)));
Assert.Equal(TimeSpan.FromMinutes(3), exception.RetryAfter);
Assert.True(PushDeliveryService.RetryDelay(0, exception.RetryAfter) >= TimeSpan.FromMinutes(3));
Assert.True(PushDeliveryService.RetryDelay(1, TimeSpan.Zero) >= TimeSpan.FromMinutes(2));
}
[Fact]
public async Task ExhaustedReplyRetriesReplaySavedSuccessWithoutSendingAgain()
{
var repository = Substitute.For<IPushDeliveryRepository>();
var sender = Substitute.For<IPushSender>();
var publisher = Substitute.For<IRabbitMqPublisher>();
repository.DeliverOnceAsync(Arg.Any<Guid>(), Arg.Any<string>(),
Arg.Any<Func<CancellationToken, Task<PushDeliveryOutcome>>>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
var service = new PushDeliveryService(repository, sender, publisher);
var id = Guid.NewGuid();
var message = new SendPushMessage
{
CorrelationContext = new CorrelationContext { CorrelationId = id.ToString(), ReplyTo = "results" },
Data = new SendPushMessageData
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10), ServiceName = "Invemory", Title = "Invemory", Body = "Test", TokenHash = new string('a', 64)
}
};
await service.CompleteFailureAsync(id, message, default);
await publisher.Received(1).PublishAsync("results",
Arg.Is<NotificationResultMessage>(x => x.Data.IsSuccess && x.Data.PushBindingId == message.Data.BindingId), default);
await sender.DidNotReceive().SendAsync(Arg.Any<Guid>(), Arg.Any<SendPushMessageData>(), Arg.Any<CancellationToken>());
}
}
@@ -0,0 +1,42 @@
namespace HrynCo.NotificationService.Services.Tests.Push;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
public sealed class SendPushDeliveryValidatorTests
{
[Theory]
[InlineData("Notification.SendPush.v1", true)]
[InlineData("Notification.SendEmail.v1", false)]
[InlineData("Notification.SendPush.v2", false)]
[InlineData("notification.sendpush.v1", false)]
[InlineData("Notification.SendPush.v1 ", false)]
[InlineData("SendPushV1", false)]
[InlineData("1", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void AcceptsOnlyTheExistingPushWireContract(string? wireType, bool expected)
{
var id = Guid.NewGuid().ToString("D");
var now = DateTimeOffset.UtcNow;
var message = new SendPushMessage
{
CorrelationContext = new CorrelationContext { CorrelationId = id },
Data = new SendPushMessageData
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ServiceName = "Invemory", Title = "Test", Body = "Test", Token = "test-device-token",
TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10)
}
};
var context = new RabbitMqMessageContext
{
QueueName = "notification.send-push", RoutingKey = "notification.send-push", Exchange = "",
MessageId = id, MessageType = wireType
};
Assert.Equal(expected, SendPushDeliveryValidator.IsValid(message, context));
}
}
@@ -5,7 +5,7 @@ using HrynCo.NotificationService.Services.Core;
namespace HrynCo.NotificationService.Services.EmailChannels.Create;
public sealed record CreateEmailChannelCommand(
string ServiceName,
Guid ServiceId,
int Priority,
EmailChannelType ChannelType,
EmailChannelSettings Settings,
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
@@ -11,21 +10,27 @@ internal sealed class CreateEmailChannelHandler
: RequestHandler<CreateEmailChannelCommand, ServiceResult<Guid>>
{
private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public CreateEmailChannelHandler(
IContextualSerilogLogger<CreateEmailChannelCommand> logger,
IEmailChannelRepository channels)
IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger)
{
_channels = channels;
_services = services;
}
protected override async Task<ServiceResult<Guid>> DoOnHandle(
CreateEmailChannelCommand request, CancellationToken cancellationToken)
{
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<Guid>("Select an existing service.", ServiceErrorCode.InvalidRequest);
var channel = new EmailChannel
{
ServiceName = request.ServiceName,
ServiceId = request.ServiceId,
Priority = request.Priority,
EmailChannelType = request.ChannelType,
Settings = request.Settings,
@@ -10,19 +10,25 @@ internal sealed class GetEmailChannelsHandler
: RequestHandler<GetEmailChannelsQuery, ServiceResult<IReadOnlyList<EmailChannel>>>
{
private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public GetEmailChannelsHandler(
IContextualSerilogLogger<GetEmailChannelsQuery> logger,
IEmailChannelRepository channels)
IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger)
{
_channels = channels;
_services = services;
}
protected override async Task<ServiceResult<IReadOnlyList<EmailChannel>>> DoOnHandle(
GetEmailChannelsQuery request, CancellationToken cancellationToken)
{
var channels = await _channels.GetByServiceAsync(request.ServiceName, cancellationToken);
var service = await _services.GetByNameAsync(request.ServiceName, cancellationToken);
if (service is null)
return Success<IReadOnlyList<EmailChannel>>([]);
var channels = await _channels.GetByServiceAsync(service.Id, cancellationToken);
return Success(channels);
}
}
@@ -7,6 +7,7 @@ namespace HrynCo.NotificationService.Services.EmailChannels.Update;
public sealed record UpdateEmailChannelCommand(
Guid Id,
Guid ServiceId,
int Priority,
EmailChannelSettings Settings,
int? DailyLimit,
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
@@ -10,13 +9,16 @@ internal sealed class UpdateEmailChannelHandler
: RequestHandler<UpdateEmailChannelCommand, ServiceResult<Unit>>
{
private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public UpdateEmailChannelHandler(
IContextualSerilogLogger<UpdateEmailChannelCommand> logger,
IEmailChannelRepository channels)
IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger)
{
_channels = channels;
_services = services;
}
protected override async Task<ServiceResult<Unit>> DoOnHandle(
@@ -27,6 +29,10 @@ internal sealed class UpdateEmailChannelHandler
if (channel is null)
return Failure<Unit>("Email channel not found.", ServiceErrorCode.NotFound);
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<Unit>("Select an existing service.", ServiceErrorCode.InvalidRequest);
channel.ServiceId = request.ServiceId;
channel.Priority = request.Priority;
channel.Settings = request.Settings;
channel.DailyLimit = request.DailyLimit;
@@ -0,0 +1,14 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.PushChannels;
// A class deliberately avoids record-generated ToString exposing the submitted secret.
public sealed class SavePushChannelCommand : IRequest<ServiceResult<Guid>>
{
public Guid Id { get; init; }
public Guid ServiceId { get; init; }
public required string FirebaseProjectId { get; init; }
public string? CredentialsJson { get; init; }
public bool IsActive { get; init; }
}
@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.PushChannels;
internal sealed class SavePushChannelHandler(
IContextualSerilogLogger<SavePushChannelCommand> logger, IPushChannelRepository channels)
: RequestHandler<SavePushChannelCommand, ServiceResult<Guid>>(logger)
{
protected override async Task<ServiceResult<Guid>> DoOnHandle(SavePushChannelCommand request, CancellationToken cancellationToken)
{
var existing = await channels.GetByIdAsync(request.Id, cancellationToken);
if (request.Id != Guid.Empty && existing is null)
return Failure<Guid>("Push channel not found.", ServiceErrorCode.NotFound);
var projectId = request.FirebaseProjectId.Trim();
var credentials = await ResolveCredentialsAsync(request, existing, projectId, cancellationToken);
if (!ValidCredentials(credentials, projectId))
return Failure<Guid>("Provide a valid Firebase service account JSON for this project. Changing project requires new credentials.", ServiceErrorCode.InvalidRequest);
var channel = new PushChannel
{
Id = existing?.Id ?? Guid.NewGuid(), ServiceId = request.ServiceId,
FirebaseProjectId = projectId, IsActive = request.IsActive
};
return await SaveAsync(channel, credentials!, existing is null, cancellationToken);
}
private async Task<string?> ResolveCredentialsAsync(SavePushChannelCommand request, PushChannel? existing,
string projectId, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(request.CredentialsJson))
return request.CredentialsJson;
if (existing is null || existing.FirebaseProjectId != projectId)
return null;
return await channels.GetCredentialsAsync(existing.Id, ct);
}
private static bool ValidCredentials(string? json, string projectId)
{
if (string.IsNullOrWhiteSpace(json)) return false;
try
{
FirebaseServiceAccountCredentials.Parse(json, projectId);
return true;
}
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
{
// Neither parser exceptions nor submitted JSON are sent to logs or clients.
return false;
}
}
private async Task<ServiceResult<Guid>> SaveAsync(PushChannel channel, string credentials, bool isNew, CancellationToken ct)
{
try
{
await channels.SaveAsync(channel, credentials, isNew, ct);
return Success(channel.Id);
}
catch (PushChannelWriteException error)
{
return Failure<Guid>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -67,7 +67,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
var url = $"/admin/services/{context.ServiceId}/delete";
var blocked = await client.GetStringAsync(url);
Assert.Contains("1 template definition(s)", blocked);
Assert.Contains("Email channels are retained", blocked);
Assert.Contains("Channels are never deleted automatically", blocked);
var fields = TemplateEditorTestFields.Read(blocked);
fields["ConfirmDelete"] = "true";
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
@@ -43,7 +43,7 @@ public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : ICla
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
@@ -14,7 +14,7 @@ public sealed class AdminSmtpEditorViewTests(TemplateDatabaseFixture fixture) :
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceName = "TestService",
ServiceId = context.ServiceId,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
@@ -15,6 +15,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
var channel = await SeedChannel(context);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["UseSsl"] = "true";
fields["Password"] = "";
fields["FromName"] = "Updated sender";
@@ -38,6 +39,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
var channel = await SeedChannel(context);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["UseSsl"] = "true";
fields["Password"] = "";
fields[field] = value;
@@ -60,6 +62,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
var channel = await SeedChannel(context);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["UseSsl"] = "true";
fields["Password"] = "replacement-test-secret";
fields["Host"] = "other.example.invalid";
@@ -78,7 +81,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/channels/create"));
fields["ServiceName"] = "NewTestService";
fields["ServiceId"] = context.ServiceId.ToString();
fields["Host"] = "smtp.example.invalid";
fields["Username"] = "test-user";
fields["FromEmail"] = "sender@example.invalid";
@@ -114,7 +117,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Port = 587, Username = "test-user",
@@ -89,7 +89,7 @@ public sealed class AdminSmtpTestControllerTests
recorder.Channel = new EmailChannel
{
Id = ChannelId,
ServiceName = "TestService",
ServiceId = Guid.NewGuid(),
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
@@ -97,7 +97,7 @@ public sealed class AdminSmtpTestControllerTests
Username = "test-user", Password = "saved-test-secret", UseSsl = true
}
};
return (new AdminChannelsController(mediator), recorder);
return (new AdminChannelsController(mediator, null!), recorder);
}
private static TestSmtpRequest Request() => new(
@@ -15,7 +15,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var request = Content("smtp.example.invalid", 2525, "test-only-user");
var request = Content(context.ServiceId, "smtp.example.invalid", 2525, "test-only-user");
var created = await context.Client.PostAsJsonAsync("/api/v1/email-channels", request);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var id = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid();
@@ -23,7 +23,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.Equal("smtp.example.invalid", original.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-user", original.GetProperty("settings").GetProperty("username").GetString());
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content("updated.example.invalid", 2526, "updated-user"))).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content(context.ServiceId, "updated.example.invalid", 2526, "updated-user"))).StatusCode);
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
var updated = Assert.Single(all.EnumerateArray());
Assert.Equal("updated.example.invalid", updated.GetProperty("settings").GetProperty("host").GetString());
@@ -33,7 +33,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.False(updated.GetProperty("settings").GetProperty("useSsl").GetBoolean());
Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString());
Assert.Equal("Test sender", updated.GetProperty("settings").GetProperty("fromName").GetString());
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=IndependentChannelService");
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=TestService");
Assert.Single(filtered.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync($"/api/v1/email-channels/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/email-channels/{id}")).StatusCode);
@@ -41,22 +41,16 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
}
[Fact]
public async Task GetAllIncludesChannelsWhoseServiceNameHasNoServiceRecord()
public async Task ForeignKeyRejectsChannelsWithoutAnExistingService()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
foreach (var serviceName in new[] { "TestService", "OrphanChannelService" })
await channels.AddAsync(new EmailChannel
{
ServiceName = serviceName, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
Assert.Equal(2, all.GetArrayLength());
Assert.Contains(all.EnumerateArray(), c => c.GetProperty("serviceName").GetString() == "OrphanChannelService");
Assert.All(all.EnumerateArray(), c => Assert.NotEqual(Guid.Empty, c.GetProperty("id").GetGuid()));
await Assert.ThrowsAsync<Microsoft.EntityFrameworkCore.DbUpdateException>(() =>
scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceId = Guid.NewGuid(), EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
}));
}
[Theory]
@@ -64,18 +58,22 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
[InlineData("invalidPort")]
[InlineData("blankHost")]
[InlineData("unsupportedType")]
[InlineData("blankService")]
[InlineData("missingService")]
[InlineData("emptyService")]
[InlineData("nameOnly")]
public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = JsonSerializer.SerializeToNode(Content("smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
var body = JsonSerializer.SerializeToNode(Content(context.ServiceId, "smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
switch (scenario)
{
case "nullSettings": body["settings"] = null; break;
case "invalidPort": body["settings"]!["port"] = 0; break;
case "blankHost": body["settings"]!["host"] = " "; break;
case "unsupportedType": body["channelType"] = 99; break;
case "blankService": body["serviceName"] = " "; break;
case "missingService": body["serviceId"] = Guid.NewGuid(); break;
case "emptyService": body["serviceId"] = Guid.Empty; break;
case "nameOnly": body.Remove("serviceId"); body["serviceName"] = "TestService"; break;
}
var response = await context.Client.PostAsJsonAsync("/api/v1/email-channels", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -83,9 +81,9 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
}
private static object Content(string host, int port, string username) => new
private static object Content(Guid serviceId, string host, int port, string username) => new
{
serviceName = "IndependentChannelService", priority = 1, channelType = 1,
serviceId, priority = 1, channelType = 1,
settings = new { host, port, username, password = "test-only-value", useSsl = false, fromEmail = "sender@example.invalid", fromName = "Test sender" },
dailyLimit = 100, monthlyLimit = 1000, warnThresholdPercent = 90, isActive = true
};
@@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceLinkTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task RenameUpdatesAllReadPathsAndResolvesChannelsByCurrentName()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using (var scope = context.App.Services.CreateScope())
{
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Invemory-Renamed";
await services.SaveAsync(service, false);
}
var read = (await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/email-channels/{channel.Id}")).GetProperty("data");
Assert.Equal(context.ServiceId, read.GetProperty("serviceId").GetGuid());
Assert.Equal("Invemory-Renamed", read.GetProperty("serviceName").GetString());
var current = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=Invemory-Renamed");
Assert.Single(current.GetProperty("data").EnumerateArray());
var previous = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=TestService");
Assert.Empty(previous.GetProperty("data").EnumerateArray());
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync("/admin/channels"));
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync($"/admin/channels/{channel.Id}"));
}
[Fact]
public async Task EditorAllowsReassignmentWithoutReplacingPasswordOrUsage()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using var scope = context.App.Services.CreateScope();
var destination = new NotificationServiceInfo { Name = "Invemory-Other" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(destination, true);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var html = await client.GetStringAsync($"/admin/channels/{channel.Id}");
Assert.Contains("<select", html);
Assert.Contains("name=\"ServiceId\"", html);
Assert.Contains("Invemory-Other", html);
Assert.DoesNotContain("name=\"ServiceName\"", html);
var fields = TemplateEditorTestFields.Read(html);
fields["ServiceId"] = destination.Id.ToString();
fields["Password"] = "";
fields["UseSsl"] = "true";
fields["IsActive"] = "true";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var readScope = context.App.Services.CreateScope();
var channels = readScope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
var saved = (await channels.GetByIdAsync(channel.Id))!;
Assert.Equal(destination.Id, saved.ServiceId);
Assert.Equal("Invemory-Other", saved.ServiceName);
Assert.Equal("test-only-password", Assert.IsType<SmtpChannelSettings>(saved.Settings).Password);
Assert.Equal(25, saved.DailyLimit);
Assert.Equal(250, saved.MonthlyLimit);
Assert.Equal(2, saved.Priority);
var summary = Assert.Single(await channels.GetAllWithUsageSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow)));
Assert.Equal(1, summary.DailySent);
Assert.Empty(await channels.GetByServiceAsync(context.ServiceId));
Assert.Single(await channels.GetByServiceAsync(destination.Id));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UpdateRejectsMissingServiceWithoutChangingChannel(bool empty)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
var response = await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{channel.Id}", new
{
serviceId = empty ? Guid.Empty : Guid.NewGuid(), priority = 9,
settings = (SmtpChannelSettings)channel.Settings, dailyLimit = 1, monthlyLimit = 2, warnThresholdPercent = 80, isActive = false
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Select an existing service.", result.GetProperty("error").GetProperty("message").GetString());
using var scope = context.App.Services.CreateScope();
var saved = (await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetByIdAsync(channel.Id))!;
Assert.Equal(context.ServiceId, saved.ServiceId);
Assert.Equal(2, saved.Priority);
Assert.Equal(25, saved.DailyLimit);
}
private static async Task<EmailChannel> SeedAsync(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceId = context.ServiceId, Priority = 2, EmailChannelType = EmailChannelType.Smtp,
DailyLimit = 25, MonthlyLimit = 250, IsActive = true,
Settings = new SmtpChannelSettings
{
Host = "smtp.invalid", Port = 587, Username = "test-user", Password = "test-only-password",
UseSsl = true, FromEmail = "sender@example.invalid", FromName = "Invemory Test"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<IEmailChannelUsageRepository>()
.IncrementUsageAsync(channel.Id, DateOnly.FromDateTime(DateTime.UtcNow));
return channel;
}
}
@@ -0,0 +1,91 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
private const string PreviousMigration = "20261023223753_AddPushDeliveries";
[Theory]
[InlineData("StoreMate-Prod", "Invemory-Prod")]
[InlineData("Invemory.Staging.Sandbox", "Invemory.Staging.Sandbox")]
public async Task BackfillPreservesChannelSettingsLimitsAndUsage(string legacyName, string currentName)
{
var connection = await PrepareAsync(legacyName, currentName);
await using (var db = TemplateDatabaseFixture.CreateContext(connection))
await db.Database.MigrateAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
var channel = Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
var service = await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync(currentName);
Assert.Equal(service!.Id, channel.ServiceId);
Assert.Equal(currentName, channel.ServiceName);
Assert.Equal(7, channel.Priority);
Assert.Equal(25, channel.DailyLimit);
Assert.Equal(250, channel.MonthlyLimit);
Assert.Equal(80, channel.WarnThresholdPercent);
Assert.False(channel.IsActive);
Assert.Equal("migration-test-value", Assert.IsType<SmtpChannelSettings>(channel.Settings).Password);
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(1L, await ScalarAsync(connection, "SELECT count(*) FROM email_channel_usage u JOIN email_channels c ON c.id = u.provider_id"));
await using var rollback = TemplateDatabaseFixture.CreateContext(connection);
await rollback.GetService<IMigrator>().MigrateAsync(PreviousMigration);
Assert.Equal(currentName, await ScalarAsync(connection, "SELECT service_name FROM email_channels"));
}
[Theory]
[InlineData("UnmappedService", false)]
[InlineData("StoreMate-Prod", true)]
public async Task MissingOrAmbiguousMappingAbortsWithoutChangingChannelData(string legacyName, bool ambiguous)
{
var connection = await PrepareAsync(legacyName, "Invemory-Prod");
if (ambiguous)
await TemplateDatabaseFixture.ExecuteAsync(connection,
"INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), 'StoreMate-Prod', gen_random_uuid(), now())");
var before = await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var error = await Assert.ThrowsAsync<PostgresException>(() => db.Database.MigrateAsync());
Assert.Contains("exactly one existing service", error.MessageText);
Assert.Equal(before, await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c"));
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(0L, await ScalarAsync(connection,
"SELECT count(*) FROM information_schema.columns WHERE table_name = 'email_channels' AND column_name = 'service_id'"));
}
private async Task<string> PrepareAsync(string legacyName, string currentName)
{
var connectionString = await fixture.CreateDatabaseAsync(legacy: true);
await using (var db = TemplateDatabaseFixture.CreateContext(connectionString))
await db.GetService<IMigrator>().MigrateAsync(PreviousMigration);
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand("""
INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), @current, gen_random_uuid(), now());
INSERT INTO email_channels(id, service_name, priority, provider_type, settings, daily_limit, monthly_limit,
warn_threshold_percent, is_active, created)
VALUES (@channel, @legacy, 7, 1, '{"Host":"smtp.invalid","Password":"migration-test-value"}', 25, 250, 80, false, now());
INSERT INTO email_channel_usage(id, provider_id, date, sent_count, created)
VALUES (gen_random_uuid(), @channel, CURRENT_DATE, 12, now());
""", connection);
command.Parameters.AddWithValue("current", currentName);
command.Parameters.AddWithValue("legacy", legacyName);
command.Parameters.AddWithValue("channel", Guid.NewGuid());
await command.ExecuteNonQueryAsync();
return connectionString;
}
private static async Task<object?> ScalarAsync(string connectionString, string sql)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand(sql, connection);
return await command.ExecuteScalarAsync();
}
}
@@ -22,7 +22,7 @@ public sealed class InterimEmailTemplateMigrationTests(TemplateDatabaseFixture f
Assert.Equal(firstServices, await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s"));
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var history = (await db.Database.GetAppliedMigrationsAsync()).ToList();
Assert.Equal(4, history.Count);
Assert.Equal(db.Database.GetMigrations().Append(InterimEmailTemplateFixture.MigrationId).Order(), history.Order());
Assert.Contains(InterimEmailTemplateFixture.MigrationId, history);
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class PushChannelAdminTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task TestsRequireAntiforgeryAndRejectMissingCredentialsWithoutSaving()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient();
var html = await client.GetStringAsync("/admin/push-channels/create");
Assert.Contains("Check Firebase authorization", html);
Assert.Contains("Recipient token", html);
Assert.Contains("test-authorization", html);
var fields = TemplateEditorTestFields.Read(html);
foreach (var action in new[] { "test-authorization", "test-send" })
{
var body = new { firebaseProjectId = "test-project", recipientToken = "test-token" };
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/admin/push-channels/" + action, body)).StatusCode);
using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/" + action);
request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]);
request.Content = JsonContent.Create(body);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>();
Assert.False(result.GetProperty("success").GetBoolean());
Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync());
}
using var scope = context.App.Services.CreateScope();
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync());
}
[Fact]
public async Task CreateAndEditKeepCredentialsOnServerAndAllowServiceReassignment()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
var response = await SaveNewAsync(client, context.ServiceId, credentials);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
var other = new NotificationServiceInfo { Name = "Invemory-Push-Test" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var html = await client.GetStringAsync($"/admin/push-channels/{channel.Id}");
Assert.DoesNotContain("PRIVATE KEY", html);
Assert.DoesNotContain("test@example.invalid", html);
Assert.Contains("Credentials are saved", html);
var fields = TemplateEditorTestFields.Read(html);
fields["ServiceId"] = other.Id.ToString();
fields["CredentialsJson"] = "";
fields["IsActive"] = "true";
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields))).StatusCode);
var saved = (await channels.GetByIdAsync(channel.Id))!;
Assert.Equal(other.Id, saved.ServiceId);
Assert.Equal("Invemory-Push-Test", saved.ServiceName);
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(credentials),
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
Assert.NotNull(await channels.GetActiveForServiceAsync(other.Id));
}
[Theory]
[InlineData("duplicate")]
[InlineData("missingService")]
[InlineData("emptyService")]
[InlineData("invalidJson")]
[InlineData("missingCredentials")]
public async Task InvalidCreateDoesNotExposeSubmittedSecretOrPersistAChannel(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
if (scenario == "duplicate")
Assert.Equal(HttpStatusCode.Redirect, (await SaveNewAsync(client, context.ServiceId, credentials)).StatusCode);
var serviceId = scenario switch { "missingService" => Guid.NewGuid(), "emptyService" => Guid.Empty, _ => context.ServiceId };
var secret = scenario switch { "invalidJson" => "invalid-secret-must-not-render", "missingCredentials" => "", _ => credentials };
var response = await SaveNewAsync(client, serviceId, secret);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("PRIVATE KEY", html);
Assert.DoesNotContain("invalid-secret-must-not-render", html);
Assert.Contains("TestService", html);
using var scope = context.App.Services.CreateScope();
var channels = await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync();
Assert.Equal(scenario == "duplicate" ? 1 : 0, channels.Count);
}
[Fact]
public async Task BlankCredentialsCannotChangeProjectAndValidationErrorsDoNotEchoSecrets()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
await SaveNewAsync(client, context.ServiceId, credentials);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/push-channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["FirebaseProjectId"] = "other-project";
fields["CredentialsJson"] = "";
var response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
Assert.Contains("Changing project requires new credentials", await response.Content.ReadAsStringAsync());
Assert.Equal("test-project", (await channels.GetByIdAsync(channel.Id))!.FirebaseProjectId);
fields["FirebaseProjectId"] = "";
fields["CredentialsJson"] = credentials;
response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
Assert.DoesNotContain("PRIVATE KEY", await response.Content.ReadAsStringAsync());
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(credentials),
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
}
[Fact]
public async Task RenameUpdatesDisplayAndInactiveChannelBlocksServiceDeletion()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
await SaveNewAsync(client, context.ServiceId, credentials);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
channel.IsActive = false;
await channels.SaveAsync(channel, credentials, false);
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Invemory-Renamed-Push";
await services.SaveAsync(service, false);
Assert.Contains(service.Name, await client.GetStringAsync("/admin/push-channels"));
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.DeleteAsync(service.Id, service.Revision));
}
private static async Task<HttpResponseMessage> SaveNewAsync(HttpClient client, Guid serviceId, string credentials)
{
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/push-channels/create"));
fields["ServiceId"] = serviceId.ToString();
fields["FirebaseProjectId"] = "test-project";
fields["CredentialsJson"] = credentials;
fields["IsActive"] = "true";
return await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
}
}
@@ -0,0 +1,17 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace HrynCo.NotificationService.Web.IntegrationTests;
internal static class PushChannelTestCredentials
{
public static string Create(string projectId = "test-project")
{
using var rsa = RSA.Create(2048);
return JsonSerializer.Serialize(new
{
type = "service_account", project_id = projectId,
client_email = "test@example.invalid", private_key = rsa.ExportPkcs8PrivateKeyPem()
});
}
}
@@ -0,0 +1,34 @@
namespace HrynCo.NotificationService.Web.IntegrationTests;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
public sealed class PushDeliveryRepositoryTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task RedeliveryReplaysOutcomeAndFailedProviderCallCanRetry()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
var id = Guid.NewGuid();
using (var first = provider.CreateScope())
{
var repository = first.ServiceProvider.GetRequiredService<IPushDeliveryRepository>();
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.DeliverOnceAsync(id, "Invemory",
_ => throw new InvalidOperationException("transient"), default));
}
using (var retry = provider.CreateScope())
{
var repository = retry.ServiceProvider.GetRequiredService<IPushDeliveryRepository>();
var result = await repository.DeliverOnceAsync(id, "Invemory",
_ => Task.FromResult(new PushDeliveryOutcome("unregistered", true)), default);
Assert.True(result.InvalidRegistration);
}
using var redelivery = provider.CreateScope();
var saved = await redelivery.ServiceProvider.GetRequiredService<IPushDeliveryRepository>()
.DeliverOnceAsync(id, "Invemory", _ => throw new Exception("must not send twice"), default);
Assert.Equal("unregistered", saved.Error);
Assert.True(saved.InvalidRegistration);
}
}
@@ -34,7 +34,7 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
}
[Fact]
public async Task ExplicitCleanupDeletesDefinitionsThenServiceAndLanguages_ButRetainsChannels()
public async Task ExplicitCleanupRequiresReassigningChannelsBeforeDeletingService()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
await SeedChannelAsync(context);
@@ -53,10 +53,21 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
Assert.Equal(service.GetProperty("revision").GetGuid(), afterDefinitionDeletion.GetProperty("revision").GetGuid());
Assert.Equal(service.GetProperty("supportedLanguageCodes").GetRawText(), afterDefinitionDeletion.GetProperty("supportedLanguageCodes").GetRawText());
Assert.All(afterDefinitionDeletion.GetProperty("languages").EnumerateArray(), l => Assert.Equal(0, l.GetProperty("versionCount").GetInt32()));
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
using (var reassignment = context.App.Services.CreateScope())
{
var other = new NotificationServiceInfo { Name = "OtherService" };
await reassignment.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var channels = reassignment.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
channel.ServiceId = other.Id;
await channels.UpdateAsync(channel);
await reassignment.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
}
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.ServiceRoot)).StatusCode);
var all = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all");
Assert.Empty(all.GetProperty("data").EnumerateArray());
Assert.Single(all.GetProperty("data").EnumerateArray());
using var scope = context.App.Services.CreateScope();
Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
@@ -145,7 +156,7 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
@@ -14,7 +14,7 @@ using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/channels")]
public class AdminChannelsController(IMediator mediator) : Controller
public class AdminChannelsController(IMediator mediator, INotificationServiceRepository services) : Controller
{
// GET /admin/channels
@@ -33,10 +33,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
// GET /admin/channels/create
[HttpGet("create")]
public IActionResult Create()
{
return View("Edit", new EmailChannelEditViewModel());
}
public Task<IActionResult> Create(CancellationToken ct) => EditorViewAsync(new EmailChannelEditViewModel(), ct);
// GET /admin/channels/{id}
[HttpGet("{id:guid}")]
@@ -55,7 +52,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
var vm = new EmailChannelEditViewModel
{
Id = channel.Id,
ServiceName = channel.ServiceName,
ServiceId = channel.ServiceId,
Priority = channel.Priority,
EmailChannelType = channel.EmailChannelType,
DailyLimit = channel.DailyLimit,
@@ -70,7 +67,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
FromName = smtp.FromName
};
return View(vm);
return await EditorViewAsync(vm, ct);
}
// POST /admin/channels/save
@@ -79,13 +76,13 @@ public class AdminChannelsController(IMediator mediator) : Controller
public async Task<IActionResult> Save(EmailChannelEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid)
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
var (password, passwordError) = await ResolvePasswordForSave(model, ct);
if (passwordError is not null)
{
ModelState.AddModelError(nameof(model.Password), passwordError);
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
}
var smtpSettings = new SmtpChannelSettings
@@ -102,7 +99,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (model.IsNew)
{
var command = new CreateEmailChannelCommand(
model.ServiceName,
model.ServiceId!.Value,
model.Priority,
EmailChannelType.Smtp,
smtpSettings,
@@ -115,13 +112,14 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
}
}
else
{
var command = new UpdateEmailChannelCommand(
model.Id,
model.ServiceId!.Value,
model.Priority,
smtpSettings,
model.DailyLimit,
@@ -133,7 +131,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
}
}
@@ -157,9 +155,12 @@ public class AdminChannelsController(IMediator mediator) : Controller
return (saved.Password, null);
}
private IActionResult EditWithErrors(EmailChannelEditViewModel model)
private async Task<IActionResult> EditorViewAsync(EmailChannelEditViewModel model, CancellationToken ct)
{
model.Password = null;
model.Services = (await services.GetAllAsync(ct))
.Select(service => new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem(service.Name, service.Id.ToString()))
.ToList();
return View("Edit", model);
}
@@ -227,5 +228,3 @@ public class AdminChannelsController(IMediator mediator) : Controller
return RedirectToAction(nameof(Index));
}
}
public record TestChannelRequest(string ToEmail);
@@ -0,0 +1,81 @@
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.PushChannels;
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using HrynCo.NotificationService.Worker.Services.Push;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/push-channels")]
public sealed class AdminPushChannelsController(
IPushChannelRepository channels, INotificationServiceRepository services, IMediator mediator,
PushChannelTestService tester) : Controller
{
[HttpPost("test-authorization")]
[ValidateAntiForgeryToken]
[RequestSizeLimit(131072)]
public async Task<IActionResult> TestAuthorization([FromBody] PushChannelTestSettings? settings, CancellationToken ct)
{
if (settings is null || !ModelState.IsValid) return BadRequest(new { success = false, message = "Invalid test settings." });
var result = await tester.TestAsync(settings, false, ct);
return Json(new { success = result.Error is null, message = result.Error ??
"OAuth access token obtained. FCM send permission and device delivery have not been verified. No push was sent." });
}
[HttpPost("test-send")]
[ValidateAntiForgeryToken]
[RequestSizeLimit(131072)]
public async Task<IActionResult> TestSend([FromBody] PushChannelTestSettings? settings, CancellationToken ct)
{
if (settings is null || !ModelState.IsValid) return BadRequest(new { success = false, message = "Invalid test settings." });
var result = await tester.TestAsync(settings, true, ct);
return Json(new { success = result.Error is null, message = result.Error ??
"Firebase accepted the test message. This does not confirm delivery or display on the device." });
}
[HttpGet("")]
public async Task<IActionResult> Index(CancellationToken ct) => View(await channels.GetAllAsync(ct));
[HttpGet("create")]
public Task<IActionResult> Create(CancellationToken ct) => EditorViewAsync(new PushChannelEditViewModel(), ct);
[HttpGet("{id:guid}")]
public async Task<IActionResult> Edit(Guid id, CancellationToken ct)
{
var channel = await channels.GetByIdAsync(id, ct);
if (channel is null) return NotFound();
return await EditorViewAsync(new PushChannelEditViewModel
{
Id = channel.Id, ServiceId = channel.ServiceId,
FirebaseProjectId = channel.FirebaseProjectId, IsActive = channel.IsActive
}, ct);
}
[HttpPost("save")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(PushChannelEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid) return await EditorViewAsync(model, ct);
var result = await mediator.Send(new SavePushChannelCommand
{
Id = model.Id, ServiceId = model.ServiceId!.Value, FirebaseProjectId = model.FirebaseProjectId,
CredentialsJson = model.CredentialsJson, IsActive = model.IsActive
}, ct);
if (result.IsSuccess) return RedirectToAction(nameof(Index));
ModelState.AddModelError(string.Empty, result.Error?.Message ?? "Could not save the push channel.");
return await EditorViewAsync(model, ct);
}
private async Task<IActionResult> EditorViewAsync(PushChannelEditViewModel model, CancellationToken ct)
{
model.CredentialsJson = null;
// Razor tag helpers prefer ModelState over the model, so clear the attempted secret too.
ModelState.Remove(nameof(model.CredentialsJson));
model.Services = (await services.GetAllAsync(ct))
.Select(s => new SelectListItem(s.Name, s.Id.ToString())).ToList();
return View("Edit", model);
}
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin;
public sealed record TestChannelRequest(string ToEmail);
@@ -9,7 +9,9 @@ public class EmailChannelEditViewModel
// ── Channel fields ─────────────────────────────────────────────────
[Required]
public string ServiceName { get; set; } = "";
public Guid? ServiceId { get; set; }
public IReadOnlyList<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> Services { get; set; } = [];
public int Priority { get; set; } = 1;
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class PushChannelEditViewModel
{
public Guid Id { get; set; }
[Required]
public Guid? ServiceId { get; set; }
[Required, StringLength(30)]
public string FirebaseProjectId { get; set; } = string.Empty;
[StringLength(32768)]
public string? CredentialsJson { get; set; }
public bool IsActive { get; set; } = true;
[BindNever]
public IReadOnlyList<SelectListItem> Services { get; set; } = [];
public bool IsNew => Id == Guid.Empty;
public string PageTitle => IsNew ? "Create Push Channel" : "Edit Push Channel";
}
@@ -1,10 +1,9 @@
using System.ComponentModel.DataAnnotations;
using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record CreateEmailChannelRequest(
[Required, StringLength(100)] string ServiceName,
Guid ServiceId,
int Priority,
EmailChannelType ChannelType,
SmtpChannelSettingsDto Settings,
@@ -2,6 +2,6 @@ using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record EmailChannelDto(Guid Id, string ServiceName, int Priority, EmailChannelType EmailChannelType,
public sealed record EmailChannelDto(Guid Id, Guid ServiceId, string ServiceName, int Priority, EmailChannelType EmailChannelType,
SmtpChannelSettingsDto Settings, int? DailyLimit, int? MonthlyLimit, int WarnThresholdPercent,
bool IsActive, DateTimeOffset Created, DateTimeOffset? Updated);
@@ -13,7 +13,7 @@ internal static class EmailChannelMapping
public static EmailChannelDto Map(EmailChannel channel)
{
var smtp = (SmtpChannelSettings)channel.Settings;
return new EmailChannelDto(channel.Id, channel.ServiceName, channel.Priority, channel.EmailChannelType,
return new EmailChannelDto(channel.Id, channel.ServiceId, channel.ServiceName, channel.Priority, channel.EmailChannelType,
new SmtpChannelSettingsDto
{
Host = smtp.Host, Port = smtp.Port, Username = smtp.Username, Password = smtp.Password,
@@ -48,7 +48,7 @@ public sealed class EmailChannelsController : ApiControllerBase
if (request.ChannelType != EmailChannelType.Smtp)
return MapServiceError(new ServiceError("Only SMTP channels are supported.", ServiceErrorCode.InvalidRequest));
var command = new CreateEmailChannelCommand(
request.ServiceName,
request.ServiceId,
request.Priority,
request.ChannelType,
EmailChannelMapping.ToSettings(request.Settings),
@@ -72,6 +72,7 @@ public sealed class EmailChannelsController : ApiControllerBase
{
var command = new UpdateEmailChannelCommand(
id,
request.ServiceId,
request.Priority,
EmailChannelMapping.ToSettings(request.Settings),
request.DailyLimit,
@@ -3,6 +3,7 @@ using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record UpdateEmailChannelRequest(
Guid ServiceId,
int Priority,
SmtpChannelSettingsDto Settings,
int? DailyLimit,
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.DAL.EF\HrynCo.NotificationService.DAL.EF.csproj" />
</ItemGroup>
@@ -17,6 +17,9 @@ builder.Services.AddControllersWithViews()
.AddRazorRuntimeCompilation();
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
builder.Services.AddNotificationServices();
builder.Services.AddTransient<HrynCo.NotificationService.Worker.Services.Push.IFirebasePushClient,
HrynCo.NotificationService.Worker.Services.Push.FirebasePushClient>();
builder.Services.AddTransient<HrynCo.NotificationService.Worker.Services.Push.PushChannelTestService>();
WebApplication app = builder.Build();
@@ -7,6 +7,24 @@
ViewData["EditorTitle"] = Model.PageTitle;
}
<style>
@@media (min-width: 768px) {
#channelForm .channel-compact-field {
flex: 0 0 auto;
width: calc(var(--channel-field-width) + var(--bs-gutter-x));
}
#channelForm .channel-service-field { --channel-field-width: 280px; }
#channelForm .channel-type-field { --channel-field-width: 200px; }
#channelForm .channel-priority-field { --channel-field-width: 110px; }
#channelForm .channel-limit-field { --channel-field-width: 160px; }
#channelForm .channel-warning-field { --channel-field-width: 130px; }
#channelForm .channel-smtp-text-field { --channel-field-width: 245px; }
#channelForm .channel-port-field { --channel-field-width: 120px; }
#channelForm .channel-from-email-field { --channel-field-width: 325px; }
}
</style>
<form id="channelForm" asp-action="Save" asp-controller="AdminChannels" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
@@ -24,25 +42,26 @@
@* Channel Settings section *@
<div class="form-section-title"><i class="bi bi-sliders me-1"></i> Channel Settings</div>
<div class="mb-4">
<label asp-for="ServiceName" class="form-label fw-semibold">Service Name</label>
<input asp-for="ServiceName" class="form-control" readonly="@(!Model.IsNew)" />
<span asp-validation-for="ServiceName" class="text-danger small"></span>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-service-field">
<label asp-for="ServiceId" class="form-label fw-semibold">Service</label>
<select asp-for="ServiceId" asp-items="Model.Services" class="form-select">
<option value="">Select a service</option>
</select>
<span asp-validation-for="ServiceId" class="text-danger small"></span>
</div>
<div class="col-6 channel-compact-field channel-priority-field">
<label asp-for="Priority" class="form-label fw-semibold">Priority</label>
<input asp-for="Priority" class="form-control" type="number" min="1" />
<span asp-validation-for="Priority" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="col-6 channel-compact-field channel-type-field">
<label asp-for="EmailChannelType" class="form-label fw-semibold">Channel Type</label>
<select asp-for="EmailChannelType" class="form-select"
asp-items="Html.GetEnumSelectList<EmailChannelType>()">
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="col-12 col-md-auto d-flex align-items-end">
<div class="form-check mb-2">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label fw-semibold">Active</label>
@@ -50,18 +69,18 @@
</div>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-limit-field">
<label asp-for="DailyLimit" class="form-label fw-semibold">Daily Limit</label>
<input asp-for="DailyLimit" class="form-control" type="number" min="0" placeholder="Unlimited" />
<span asp-validation-for="DailyLimit" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="col-12 channel-compact-field channel-limit-field">
<label asp-for="MonthlyLimit" class="form-label fw-semibold">Monthly Limit</label>
<input asp-for="MonthlyLimit" class="form-control" type="number" min="0" placeholder="Unlimited" />
<span asp-validation-for="MonthlyLimit" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="col-12 channel-compact-field channel-warning-field">
<label asp-for="WarnThresholdPercent" class="form-label fw-semibold">Warn Threshold (%)</label>
<input asp-for="WarnThresholdPercent" class="form-control" type="number" min="1" max="100" />
<span asp-validation-for="WarnThresholdPercent" class="text-danger small"></span>
@@ -71,26 +90,26 @@
@* SMTP Settings section *@
<div class="form-section-title mt-4"><i class="bi bi-envelope-at me-1"></i> SMTP Settings</div>
<div class="row mb-4">
<div class="col-md-9">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="Host" class="form-label fw-semibold">Host</label>
<input asp-for="Host" class="form-control" placeholder="smtp.example.com" />
<span asp-validation-for="Host" class="text-danger small"></span>
</div>
<div class="col-md-3">
<div class="col-12 channel-compact-field channel-port-field">
<label asp-for="Port" class="form-label fw-semibold">Port</label>
<input asp-for="Port" class="form-control" type="number" min="1" max="65535" />
<span asp-validation-for="Port" class="text-danger small"></span>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="Username" class="form-label fw-semibold">Username</label>
<input asp-for="Username" class="form-control" autocomplete="new-password" />
<span asp-validation-for="Username" class="text-danger small"></span>
</div>
<div class="col-md-6">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="Password" class="form-label fw-semibold">Password</label>
<input asp-for="Password" class="form-control" type="password" autocomplete="new-password" />
<span asp-validation-for="Password" class="text-danger small"></span>
@@ -108,13 +127,13 @@
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-from-email-field">
<label asp-for="FromEmail" class="form-label fw-semibold">From Email</label>
<input asp-for="FromEmail" class="form-control" type="email" placeholder="noreply@example.com" />
<span asp-validation-for="FromEmail" class="text-danger small"></span>
</div>
<div class="col-md-6">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="FromName" class="form-label fw-semibold">From Name</label>
<input asp-for="FromName" class="form-control" placeholder="My Service" />
<span asp-validation-for="FromName" class="text-danger small"></span>
@@ -0,0 +1,161 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model PushChannelEditViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = Model.PageTitle;
ViewData["EditorTitle"] = Model.PageTitle;
}
<style>
@@media (min-width: 768px) {
#pushChannelForm .push-service-field { flex: 0 0 auto; width: calc(280px + var(--bs-gutter-x)); }
#pushChannelForm .push-project-field { flex: 0 0 auto; width: calc(280px + var(--bs-gutter-x)); }
#pushChannelForm .push-credentials-field { flex: 0 0 auto; width: calc(440px + var(--bs-gutter-x)); }
}
</style>
<form id="pushChannelForm" asp-action="Save" asp-controller="AdminPushChannels" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
@if (!ViewData.ModelState.IsValid)
{
<div asp-validation-summary="All" class="alert alert-danger"></div>
}
<div class="row g-3 mb-4">
<div class="col-12 push-service-field">
<label asp-for="ServiceId" class="form-label fw-semibold">Service</label>
<select asp-for="ServiceId" asp-items="Model.Services" class="form-select">
<option value="">Select a service</option>
</select>
<span asp-validation-for="ServiceId" class="text-danger small"></span>
</div>
<div class="col-12 push-project-field">
<label asp-for="FirebaseProjectId" class="form-label fw-semibold">Firebase Project ID</label>
<input asp-for="FirebaseProjectId" class="form-control" maxlength="30" />
<span asp-validation-for="FirebaseProjectId" class="text-danger small"></span>
</div>
<div class="col-12 col-md-auto d-flex align-items-end">
<div class="form-check mb-2">
<input asp-for="IsActive" class="form-check-input" />
<label asp-for="IsActive" class="form-check-label fw-semibold">Active</label>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 push-credentials-field">
<label asp-for="CredentialsJson" class="form-label fw-semibold">Firebase service account JSON</label>
<textarea asp-for="CredentialsJson" class="form-control font-monospace" rows="7" maxlength="32768" autocomplete="off" spellcheck="false" aria-describedby="credentialsHelp"></textarea>
<div id="credentialsHelp" class="form-text">
@if (Model.IsNew)
{
<span>Required. Use the service account JSON for this Firebase project.</span>
}
else
{
<span>Credentials are saved. Leave blank to keep them, or paste a replacement. Changing the project requires new credentials.</span>
}
</div>
<span asp-validation-for="CredentialsJson" class="text-danger small"></span>
<div class="mt-3">
<button type="button" class="btn btn-outline-success" id="pushAuthorizationBtn">Check Firebase authorization</button>
</div>
<div id="pushAuthorizationResult" class="alert d-none mt-3" role="status" aria-live="polite"></div>
</div>
</div>
</form>
<div class="modal fade" id="pushTestModal" tabindex="-1" aria-labelledby="pushTestTitle">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pushTestTitle">Send Test Push</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small">Tests the current form settings without saving. Blank credentials use the saved credentials only when the Firebase project is unchanged.</p>
<p class="text-muted small">Sends a neutral notification directly through Firebase. This tests the channel, not Invemory account binding, navigation or background handling. Use your own test device only.</p>
<label for="pushRecipientToken" class="form-label fw-semibold">Recipient token</label>
<textarea id="pushRecipientToken" class="form-control font-monospace" rows="3" maxlength="4096" autocomplete="off" spellcheck="false"></textarea>
<div class="form-text">Not saved as channel configuration. Cleared after the test or when this dialog closes.</div>
<div id="pushSendResult" class="alert d-none mt-3" role="status" aria-live="polite"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" id="pushSendBtn">Send test push</button>
</div>
</div>
</div>
</div>
@section FormActions {
<button type="submit" form="pushChannelForm" class="btn btn-primary"><i class="bi bi-floppy me-1"></i> Save</button>
<button type="button" class="btn btn-success" id="pushTestModalBtn"><i class="bi bi-send me-1"></i> Send test push…</button>
<a href="/admin/push-channels" class="btn btn-secondary">Cancel</a>
}
<script>
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('pushChannelForm');
const modal = document.getElementById('pushTestModal');
const token = document.getElementById('pushRecipientToken');
const authorizationButton = document.getElementById('pushAuthorizationBtn');
const sendButton = document.getElementById('pushSendBtn');
const modalButton = document.getElementById('pushTestModalBtn');
let busy = false;
function showResult(target, success, message) {
target.className = 'alert mt-3 ' + (success ? 'alert-success' : 'alert-danger');
target.textContent = message;
}
function currentSettings(send) {
const id = form.querySelector('[name="Id"]').value;
return {
channelId: id === '00000000-0000-0000-0000-000000000000' ? null : id,
firebaseProjectId: form.querySelector('[name="FirebaseProjectId"]').value,
credentialsJson: form.querySelector('[name="CredentialsJson"]').value,
recipientToken: send ? token.value.trim() : null
};
}
async function runTest(send) {
if (busy) return;
const result = document.getElementById(send ? 'pushSendResult' : 'pushAuthorizationResult');
const payload = currentSettings(send);
if (send && !payload.recipientToken) {
showResult(result, false, 'Enter a recipient FCM token.');
return;
}
busy = true;
authorizationButton.disabled = sendButton.disabled = modalButton.disabled = true;
showResult(result, true, send ? 'Sending test message…' : 'Checking OAuth authorization…');
try {
const response = await fetch('/admin/push-channels/' + (send ? 'test-send' : 'test-authorization'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': form.querySelector('[name="__RequestVerificationToken"]').value
},
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Request failed.');
const data = await response.json();
showResult(result, data.success, data.message);
} catch {
showResult(result, false, 'The test request failed. Check connectivity and try again. Acceptance was not confirmed.');
} finally {
busy = false;
authorizationButton.disabled = sendButton.disabled = modalButton.disabled = false;
if (send) token.value = '';
}
}
authorizationButton.addEventListener('click', () => runTest(false));
sendButton.addEventListener('click', () => runTest(true));
modalButton.addEventListener('click', () => {
document.getElementById('pushSendResult').className = 'alert d-none';
bootstrap.Modal.getOrCreateInstance(modal).show();
});
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; });
});
</script>
@@ -0,0 +1,40 @@
@using HrynCo.NotificationService.DAL.Abstract.PushChannels
@model IReadOnlyList<PushChannel>
@{ ViewData["Title"] = "Push Channels"; }
<div class="page-header">
<h2><i class="bi bi-phone"></i> Push Channels</h2>
<a href="/admin/push-channels/create" class="btn btn-primary btn-sm">Create New Channel</a>
</div>
@if (Model.Count == 0)
{
<div class="card shadow-sm table-card">
<div class="empty-state">
<i class="bi bi-phone"></i>
<p class="mt-2 mb-0">No push channels found.</p>
<a href="/admin/push-channels/create" class="btn btn-primary btn-sm mt-3">Create First Channel</a>
</div>
</div>
}
else
{
<div class="card shadow-sm table-card">
<div class="table-responsive">
<table class="table table-hover admin-data-table table-sm mb-0">
<thead><tr><th scope="col">Service</th><th scope="col">Firebase Project ID</th><th scope="col">Status</th><th scope="col">Actions</th></tr></thead>
<tbody>
@foreach (var channel in Model)
{
<tr>
<td>@channel.ServiceName</td>
<td>@channel.FirebaseProjectId</td>
<td><span class="badge @(channel.IsActive ? "bg-success" : "bg-secondary")">@(channel.IsActive ? "Active" : "Inactive")</span></td>
<td><a href="/admin/push-channels/@channel.Id" class="btn btn-sm btn-link" aria-label="Edit push channel for @channel.ServiceName">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@@ -14,7 +14,7 @@ else
{
<div class="alert alert-danger">This permanently deletes the service and its language catalog.</div>
}
<p>Email channels are retained. Their service names are independent and are not renamed or deleted.</p>
<p>Reassign this service's push channel and reassign or remove its email channels before deleting the service. Channels are never deleted automatically.</p>
<form asp-action="DeleteService" asp-route-serviceId="@Model.ServiceId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Revision" type="hidden" />
@@ -15,7 +15,7 @@
<label asp-for="Name" class="form-label fw-semibold">Service name</label>
<input asp-for="Name" class="form-control mb-3" maxlength="100" />
<h3 class="h5">Language codes</h3>
<p class="text-muted">Codes are case-sensitive and belong to this service. Existing template versions use these codes when sending. Renaming a service or code changes sender lookup; align publishers and email channel service names.</p>
<p class="text-muted">Codes are case-sensitive and belong to this service. Existing template versions use these codes when sending. Renaming a service or code changes sender lookup; update publishers to use the current names. Email channels remain linked to this service automatically.</p>
<div class="table-responsive">
<table class="table admin-data-table service-language-table"><thead><tr><th scope="col">Code</th><th scope="col">Versions</th><th scope="col" class="service-language-actions">Actions</th></tr></thead><tbody id="serviceLanguages">
@for (var i = 0; i < Model.Languages.Count; i++)
@@ -2,6 +2,7 @@
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? "";
bool isServices = currentController.Equals("AdminServices", StringComparison.OrdinalIgnoreCase)
|| currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
bool isPushChannels = currentController.Equals("AdminPushChannels", StringComparison.OrdinalIgnoreCase);
bool isChannels = currentController.Equals("AdminChannels", StringComparison.OrdinalIgnoreCase);
}
<!DOCTYPE html>
@@ -34,6 +35,11 @@
<i class="bi bi-broadcast"></i> Email Channels
</a>
</li>
<li class="nav-item">
<a class="nav-link @(isPushChannels ? "active" : "")" href="/admin/push-channels">
<i class="bi bi-phone"></i> Push Channels
</a>
</li>
</ul>
</nav>
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
internal sealed class SendEmailService : ISendEmailService
{
private readonly IEmailChannelRepository _channelRepository;
private readonly INotificationServiceRepository _services;
private readonly IEmailChannelUsageRepository _usageRepository;
private readonly IEmailTemplateService _templateService;
private readonly IEmailTemplateRenderingService _templateRenderingService;
@@ -18,6 +19,7 @@ internal sealed class SendEmailService : ISendEmailService
public SendEmailService(
IEmailChannelRepository channelRepository,
INotificationServiceRepository services,
IEmailChannelUsageRepository usageRepository,
IEmailTemplateService templateService,
IEmailTemplateRenderingService templateRenderingService,
@@ -26,6 +28,7 @@ internal sealed class SendEmailService : ISendEmailService
ILogger<SendEmailService> logger)
{
_channelRepository = channelRepository;
_services = services;
_usageRepository = usageRepository;
_templateService = templateService;
_templateRenderingService = templateRenderingService;
@@ -93,7 +96,9 @@ internal sealed class SendEmailService : ISendEmailService
private async Task<EmailChannel> ResolveChannelAsync(string serviceName, CancellationToken ct)
{
var channels = await _channelRepository.GetByServiceAsync(serviceName, ct);
var service = await _services.GetByNameAsync(serviceName, ct)
?? throw new InvalidOperationException($"Service '{serviceName}' does not exist.");
var channels = await _channelRepository.GetByServiceAsync(service.Id, ct);
return channels
.Where(c => c.IsActive)
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="HrynCo.RabbitMq" />
<PackageReference Include="FirebaseAdmin" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
@@ -0,0 +1,38 @@
using System.Security.Cryptography;
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed class FcmPushSender(
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client) : IPushSender
{
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken)
{
if (data.ExpiresAt <= DateTimeOffset.UtcNow) return new PushDeliveryOutcome("Push expired.");
var service = await services.GetByNameAsync(data.ServiceName, cancellationToken);
if (service is null) return new PushDeliveryOutcome("Push service is not configured.");
var channel = await channels.GetActiveForServiceAsync(service.Id, cancellationToken);
if (channel is null) return new PushDeliveryOutcome("No active push channel is configured for this service.");
var credentials = await channels.GetCredentialsAsync(channel.Id, cancellationToken);
return await SendWithCredentialsAsync(messageId, data, channel.FirebaseProjectId, credentials, cancellationToken);
}
private async Task<PushDeliveryOutcome> SendWithCredentialsAsync(Guid messageId, SendPushMessageData data,
string projectId, string? json, CancellationToken ct)
{
try
{
var credentials = FirebaseServiceAccountCredentials.Parse(json ?? string.Empty, projectId);
return await client.SendAsync(messageId, data, credentials, ct);
}
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
{
// Never propagate credential parser details into broker retry logs or replies.
return new PushDeliveryOutcome("The push channel Firebase configuration is invalid.");
}
}
}
@@ -0,0 +1,6 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed class FcmPushSettings
{
public bool Enabled { get; set; }
}
@@ -0,0 +1,126 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
using FirebaseAdmin.Messaging;
using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class FirebasePushClient : IFirebasePushClient
{
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
{
var remaining = data.ExpiresAt - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) return new PushDeliveryOutcome("Push expired.");
return await SendMessageAsync(new Message
{
Token = data.Token,
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining },
Data = new Dictionary<string, string>
{
["version"] = "1", ["messageId"] = messageId.ToString("D"),
["bindingId"] = data.BindingId.ToString("D"), ["title"] = data.Title,
["body"] = data.Body, ["route"] = data.Route,
["expiresAt"] = data.ExpiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
}
}, credentials, cancellationToken);
}
public Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken) => SendMessageAsync(new Message
{
Token = token,
Notification = new Notification
{
Title = "Notification Service test",
Body = "This is a test notification from the channel administration page."
},
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = TimeSpan.FromMinutes(5) }
}, credentials, cancellationToken);
public async Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var credential = CreateCredential(credentials).CreateScoped("https://www.googleapis.com/auth/firebase.messaging");
await credential.UnderlyingCredential.GetAccessTokenForRequestAsync(cancellationToken: timeout.Token);
return new PushDeliveryOutcome(null);
}
catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException)
{
return new PushDeliveryOutcome("Firebase OAuth authorization failed.");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return new PushDeliveryOutcome("Firebase authorization check timed out.");
}
catch (HttpRequestException)
{
return new PushDeliveryOutcome("Could not reach Firebase authorization service.");
}
}
private static GoogleCredential CreateCredential(FirebaseServiceAccountCredentials credentials) =>
GoogleCredential.FromServiceAccountCredential(new ServiceAccountCredential(
new ServiceAccountCredential.Initializer(credentials.ClientEmail)
{ ProjectId = credentials.ProjectId }.FromPrivateKey(credentials.PrivateKey)));
private static async Task<PushDeliveryOutcome> SendMessageAsync(Message message,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
FirebaseApp? app = null;
try
{
app = FirebaseApp.Create(new AppOptions
{
ProjectId = credentials.ProjectId,
Credential = CreateCredential(credentials)
}, "push-" + Guid.NewGuid().ToString("N"));
await FirebaseMessaging.GetMessaging(app).SendAsync(message, timeout.Token);
return new PushDeliveryOutcome(null);
}
catch (FirebaseMessagingException exception)
{
var header = exception.HttpResponse?.Headers.RetryAfter;
var retryAfter = header?.Delta ?? (header?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.Zero;
return Classify(exception.MessagingErrorCode, retryAfter);
}
catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException)
{
return new PushDeliveryOutcome("FCM authorization failed.");
}
catch (FirebaseException)
{
return new PushDeliveryOutcome("FCM configuration failed.");
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new PushRetryException(TimeSpan.FromMinutes(1));
}
catch (HttpRequestException)
{
throw new PushRetryException(TimeSpan.FromMinutes(1));
}
finally
{
app?.Delete();
}
}
internal static PushDeliveryOutcome Classify(MessagingErrorCode? code, TimeSpan retryAfter = default) => code switch
{
MessagingErrorCode.Unregistered => new PushDeliveryOutcome("FCM registration is no longer valid.", true),
MessagingErrorCode.Unavailable or MessagingErrorCode.Internal or MessagingErrorCode.QuotaExceeded =>
throw new PushRetryException(retryAfter),
MessagingErrorCode.SenderIdMismatch => new PushDeliveryOutcome("FCM sender configuration does not match."),
MessagingErrorCode.InvalidArgument => new PushDeliveryOutcome("FCM rejected the push payload."),
_ => new PushDeliveryOutcome("FCM authorization or configuration failure.")
};
}
@@ -0,0 +1,13 @@
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
namespace HrynCo.NotificationService.Worker.Services.Push;
public interface IFirebasePushClient
{
Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
}
@@ -0,0 +1,9 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
public interface IPushSender
{
Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken);
}
@@ -0,0 +1,49 @@
using System.Security.Cryptography;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed class PushChannelTestService(IPushChannelRepository channels, IFirebasePushClient firebase)
{
public async Task<PushDeliveryOutcome> TestAsync(PushChannelTestSettings settings, bool send, CancellationToken ct)
{
if (send && !ValidToken(settings.RecipientToken))
return new PushDeliveryOutcome("Enter a valid recipient FCM token.");
var project = settings.FirebaseProjectId?.Trim() ?? "";
var json = await ResolveCredentialsAsync(settings, project, ct);
if (string.IsNullOrWhiteSpace(json))
return new PushDeliveryOutcome("Provide credentials for this project. Saved credentials can only be used with the unchanged project.");
return await ExecuteAsync(json, project, settings.RecipientToken, send, ct);
}
private async Task<string?> ResolveCredentialsAsync(PushChannelTestSettings settings, string project, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(settings.CredentialsJson)) return settings.CredentialsJson;
if (settings.ChannelId is not Guid id) return null;
var saved = await channels.GetByIdAsync(id, ct);
return saved?.FirebaseProjectId == project ? await channels.GetCredentialsAsync(id, ct) : null;
}
private async Task<PushDeliveryOutcome> ExecuteAsync(string json, string project, string? token, bool send, CancellationToken ct)
{
try
{
var credentials = FirebaseServiceAccountCredentials.Parse(json, project);
return send ? await firebase.SendTestAsync(token!.Trim(), credentials, ct)
: await firebase.CheckAuthorizationAsync(credentials, ct);
}
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
{
return new PushDeliveryOutcome("Provide a valid Firebase service account JSON for this project.");
}
catch (PushRetryException)
{
return new PushDeliveryOutcome("Firebase is temporarily unavailable or throttled. Try again later; acceptance was not confirmed.");
}
}
private static bool ValidToken(string? token) => !string.IsNullOrWhiteSpace(token)
&& token.Trim().Length <= 4096 && !token.Trim().Any(char.IsWhiteSpace);
}
@@ -0,0 +1,9 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed class PushChannelTestSettings
{
public Guid? ChannelId { get; set; }
public string? FirebaseProjectId { get; set; }
public string? CredentialsJson { get; set; }
public string? RecipientToken { get; set; }
}
@@ -0,0 +1,15 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
using System.Diagnostics.Metrics;
public static class PushDeliveryMetrics
{
private static readonly Meter Meter = new("HrynCo.NotificationService.Push", "1.0");
private static readonly Counter<long> Outcomes = Meter.CreateCounter<long>("push.provider.outcomes");
private static readonly Counter<long> Retries = Meter.CreateCounter<long>("push.provider.retries");
public static void RecordOutcome(bool succeeded) =>
Outcomes.Add(1, new KeyValuePair<string, object?>("outcome", succeeded ? "accepted" : "failed"));
public static void RecordRetry() => Retries.Add(1);
}
@@ -0,0 +1,71 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using Hrynco.RabbitMq;
public sealed class PushDeliveryService(IPushDeliveryRepository deliveries, IPushSender sender, IRabbitMqPublisher publisher)
{
public async Task ProcessAsync(Guid messageId, SendPushMessage message, CancellationToken cancellationToken)
{
PushDeliveryOutcome outcome = await DeliverWithRetryAsync(messageId, message.Data, cancellationToken);
await PublishResultAsync(message, outcome, cancellationToken);
}
private async Task<PushDeliveryOutcome> DeliverWithRetryAsync(Guid messageId, SendPushMessageData data,
CancellationToken cancellationToken)
{
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
return await deliveries.DeliverOnceAsync(messageId, data.ServiceName,
ct => SendAsync(messageId, data, ct), cancellationToken);
}
catch (PushRetryException exception)
{
var delay = RetryDelay(attempt, exception.RetryAfter);
if (attempt == 2 || DateTimeOffset.UtcNow.Add(delay) >= data.ExpiresAt) break;
PushDeliveryMetrics.RecordRetry();
// The failed transaction has been disposed; do not hold a DB lock while backing off.
await Task.Delay(delay, cancellationToken);
}
}
return await deliveries.DeliverOnceAsync(messageId, data.ServiceName,
_ => Task.FromResult(new PushDeliveryOutcome("Push delivery retries exhausted or message expired.")), cancellationToken);
}
private async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken)
{
var outcome = await sender.SendAsync(messageId, data, cancellationToken);
PushDeliveryMetrics.RecordOutcome(outcome.Error is null);
return outcome;
}
internal static TimeSpan RetryDelay(int attempt, TimeSpan retryAfter) =>
TimeSpan.FromSeconds(Math.Max(60 * Math.Pow(2, attempt), retryAfter.TotalSeconds) + Random.Shared.NextDouble() * 5);
public async Task CompleteFailureAsync(Guid messageId, SendPushMessage message, CancellationToken cancellationToken)
{
var outcome = await deliveries.DeliverOnceAsync(messageId, message.Data.ServiceName,
_ => Task.FromResult(new PushDeliveryOutcome("Push processing retries exhausted.")), cancellationToken);
await PublishResultAsync(message, outcome, cancellationToken);
}
public Task PublishResultAsync(SendPushMessage message, PushDeliveryOutcome outcome, CancellationToken cancellationToken)
{
var replyTo = message.CorrelationContext.ReplyTo;
if (string.IsNullOrWhiteSpace(replyTo)) return Task.CompletedTask;
return publisher.PublishAsync(replyTo, new NotificationResultMessage
{
CorrelationContext = message.CorrelationContext with { ReplyTo = null },
Data = new NotificationResultData
{
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = "PushDiagnostic",
Timestamp = DateTimeOffset.UtcNow, ErrorMessage = outcome.Error,
PushRegistrationId = message.Data.RegistrationId, PushBindingId = message.Data.BindingId,
PushTokenHash = message.Data.TokenHash, InvalidPushRegistration = outcome.InvalidRegistration
}
}, cancellationToken);
}
}
@@ -0,0 +1,18 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
using HrynCo.NotificationService.Contracts.Messages;
public static class PushMessageValidator
{
public static bool IsValid(SendPushMessage? message)
{
if (message?.Data is not { } data || message.CorrelationContext is null) return false;
return Guid.TryParse(message.CorrelationContext.CorrelationId, out _) &&
data.Version == 1 && data.RegistrationId != Guid.Empty && data.BindingId != Guid.Empty &&
data.ServiceName is { Length: > 0 and <= 100 } && data.UserId != Guid.Empty &&
data.Token is { Length: > 0 and <= 2048 } && data.TokenHash is { Length: 64 } &&
data.Title is { Length: > 0 and <= 100 } && data.Body is { Length: > 0 and <= 500 } &&
data.Route == "/home" && data.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(15);
}
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed class PushRetryException(TimeSpan retryAfter)
: Exception("FCM temporarily unavailable; retry required.")
{
public TimeSpan RetryAfter { get; } = retryAfter;
}
@@ -20,5 +20,18 @@ builder.Services.Configure<RabbitMqSettings>(
builder.Services.AddHostedService<SendEmailConsumer>();
var pushSettings = builder.Configuration.GetSection("FcmPush")
.Get<HrynCo.NotificationService.Worker.Services.Push.FcmPushSettings>() ?? new();
builder.Services.AddSingleton(pushSettings);
if (pushSettings.Enabled)
{
builder.Services.AddScoped<HrynCo.NotificationService.Worker.Services.Push.IFirebasePushClient,
HrynCo.NotificationService.Worker.Services.Push.FirebasePushClient>();
builder.Services.AddScoped<HrynCo.NotificationService.Worker.Services.Push.IPushSender,
HrynCo.NotificationService.Worker.Services.Push.FcmPushSender>();
builder.Services.AddScoped<HrynCo.NotificationService.Worker.Services.Push.PushDeliveryService>();
builder.Services.AddHostedService<SendPushConsumer>();
}
var host = builder.Build();
host.Run();
@@ -8,7 +8,6 @@ using Microsoft.Extensions.Options;
public sealed class SendEmailConsumer : RabbitMqConsumerBase<SendEmailMessage, SendEmailMessageData>
{
internal const string IncomingQueue = "notification.send-email";
internal const string SupportedMessageType = "Notification.SendEmail.v1";
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SendEmailConsumer> _logger;
@@ -33,7 +32,7 @@ public sealed class SendEmailConsumer : RabbitMqConsumerBase<SendEmailMessage, S
validationError = SendEmailDeliveryValidator.GetValidationError(
message,
context,
SupportedMessageType);
SupportedMessageType.SendEmailV1);
return validationError is null;
}
@@ -9,12 +9,12 @@ internal static class SendEmailDeliveryValidator
public static string? GetValidationError(
SendEmailMessage message,
RabbitMqMessageContext context,
string supportedMessageType)
SupportedMessageType supportedMessageType)
{
if (string.IsNullOrWhiteSpace(context.MessageId))
return "AMQP MessageId is required.";
if (!string.Equals(context.MessageType, supportedMessageType, StringComparison.Ordinal))
if (SupportedMessageTypeParser.Parse(context.MessageType) != supportedMessageType)
return $"Unsupported AMQP message type '{context.MessageType ?? "<missing>"}'.";
try
@@ -0,0 +1,37 @@
namespace HrynCo.NotificationService.Worker;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
using Microsoft.Extensions.Options;
public sealed class SendPushConsumer(
IOptionsMonitor<RabbitMqSettings> options, IServiceScopeFactory scopes,
ILogger<SendPushConsumer> logger)
: RabbitMqConsumerBase<SendPushMessage, SendPushMessageData>(options, logger)
{
protected override string QueueName => "notification.send-push";
protected override bool TryValidateMessage(SendPushMessage message, RabbitMqMessageContext context, out string? validationError)
{
bool valid = SendPushDeliveryValidator.IsValid(message, context);
validationError = valid ? null : "Invalid push envelope.";
return valid;
}
protected override async Task HandleMessageAsync(SendPushMessage message, RabbitMqMessageContext context,
CancellationToken cancellationToken)
{
await using var scope = scopes.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<PushDeliveryService>()
.ProcessAsync(Guid.Parse(context.MessageId!), message, cancellationToken);
}
protected override async Task HandleMessageRetriesExhaustedAsync(SendPushMessage message,
RabbitMqMessageContext context, Exception exception, CancellationToken cancellationToken)
{
await using var scope = scopes.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<PushDeliveryService>()
.CompleteFailureAsync(Guid.Parse(context.MessageId!), message, cancellationToken);
}
}
@@ -0,0 +1,13 @@
namespace HrynCo.NotificationService.Worker;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
internal static class SendPushDeliveryValidator
{
public static bool IsValid(SendPushMessage message, RabbitMqMessageContext context) =>
SupportedMessageTypeParser.Parse(context.MessageType) == SupportedMessageType.SendPushV1 &&
Guid.TryParse(context.MessageId, out _) && context.MessageId == message.CorrelationContext?.CorrelationId &&
PushMessageValidator.IsValid(message);
}
@@ -0,0 +1,13 @@
namespace HrynCo.NotificationService.Worker;
using HrynCo.NotificationService.Contracts.Messages;
internal static class SupportedMessageTypeParser
{
public static SupportedMessageType? Parse(string? value) => value switch
{
"Notification.SendEmail.v1" => SupportedMessageType.SendEmailV1,
"Notification.SendPush.v1" => SupportedMessageType.SendPushV1,
_ => null
};
}
+4
View File
@@ -4,6 +4,7 @@ Production image references now use `registry.hrynco.com`, preserving the existi
## Documentation
- [Push channels and Android FCM](docs/android-push.md)
- [Email template definitions and service languages](docs/email-template-localizations.md)
- [Service and whole-template API for AI clients](docs/email-template-definition-api.md)
- [ItemTracker outbox email consumer](docs/itemtracker-outbox-email-consumer.md)
@@ -11,6 +12,9 @@ Production image references now use `registry.hrynco.com`, preserving the existi
## Windows host development
Incoming RabbitMQ contract names and typed validation are documented in
[Supported message types](docs/supported-message-types.md).
For the owner's host development workflow, run Web in Rider with the Development
profile (`localhost:5433` PostgreSQL, HTTP `localhost:5188`) and keep Docker Web
stopped. Infrastructure can remain in the existing Docker stack. Web does not apply

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