diff --git a/Directory.Packages.props b/Directory.Packages.props index 618da09..db9f6f5 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + diff --git a/HrynCo.NotificationService.Contracts/Messages/NotificationResultData.cs b/HrynCo.NotificationService.Contracts/Messages/NotificationResultData.cs index 877389b..c43ceb4 100644 --- a/HrynCo.NotificationService.Contracts/Messages/NotificationResultData.cs +++ b/HrynCo.NotificationService.Contracts/Messages/NotificationResultData.cs @@ -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; } diff --git a/HrynCo.NotificationService.Contracts/Messages/SendPushMessage.cs b/HrynCo.NotificationService.Contracts/Messages/SendPushMessage.cs new file mode 100644 index 0000000..9bba919 --- /dev/null +++ b/HrynCo.NotificationService.Contracts/Messages/SendPushMessage.cs @@ -0,0 +1,9 @@ +namespace HrynCo.NotificationService.Contracts.Messages; + +using Hrynco.RabbitMq; + +public sealed record SendPushMessage : IRabbitMqMessage +{ + public CorrelationContext CorrelationContext { get; set; } = default!; + public SendPushMessageData Data { get; set; } = default!; +} diff --git a/HrynCo.NotificationService.Contracts/Messages/SendPushMessageData.cs b/HrynCo.NotificationService.Contracts/Messages/SendPushMessageData.cs new file mode 100644 index 0000000..c84e70c --- /dev/null +++ b/HrynCo.NotificationService.Contracts/Messages/SendPushMessageData.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.Contracts/Messages/SupportedMessageType.cs b/HrynCo.NotificationService.Contracts/Messages/SupportedMessageType.cs new file mode 100644 index 0000000..4906ccb --- /dev/null +++ b/HrynCo.NotificationService.Contracts/Messages/SupportedMessageType.cs @@ -0,0 +1,7 @@ +namespace HrynCo.NotificationService.Contracts.Messages; + +public enum SupportedMessageType +{ + SendEmailV1, + SendPushV1 +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs index 5b1d1c6..3c78878 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Providers/EmailChannel.cs @@ -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; } diff --git a/HrynCo.NotificationService.DAL.Abstract/Push/IPushDeliveryRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Push/IPushDeliveryRepository.cs new file mode 100644 index 0000000..0209d7a --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Push/IPushDeliveryRepository.cs @@ -0,0 +1,7 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Push; + +public interface IPushDeliveryRepository +{ + Task DeliverOnceAsync(Guid messageId, string serviceName, + Func> deliver, CancellationToken cancellationToken); +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Push/PushDeliveryOutcome.cs b/HrynCo.NotificationService.DAL.Abstract/Push/PushDeliveryOutcome.cs new file mode 100644 index 0000000..6170a0f --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/Push/PushDeliveryOutcome.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.DAL.Abstract.Push; + +public sealed record PushDeliveryOutcome(string? Error, bool InvalidRegistration = false); diff --git a/HrynCo.NotificationService.DAL.Abstract/PushChannels/FirebaseServiceAccountCredentials.cs b/HrynCo.NotificationService.DAL.Abstract/PushChannels/FirebaseServiceAccountCredentials.cs new file mode 100644 index 0000000..9105c83 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/PushChannels/FirebaseServiceAccountCredentials.cs @@ -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; + } +} diff --git a/HrynCo.NotificationService.DAL.Abstract/PushChannels/IPushChannelRepository.cs b/HrynCo.NotificationService.DAL.Abstract/PushChannels/IPushChannelRepository.cs new file mode 100644 index 0000000..4e58a26 --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/PushChannels/IPushChannelRepository.cs @@ -0,0 +1,10 @@ +namespace HrynCo.NotificationService.DAL.Abstract.PushChannels; + +public interface IPushChannelRepository +{ + Task> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default); + Task GetCredentialsAsync(Guid id, CancellationToken ct = default); + Task SaveAsync(PushChannel channel, string credentialsJson, bool isNew, CancellationToken ct = default); +} diff --git a/HrynCo.NotificationService.DAL.Abstract/PushChannels/PushChannel.cs b/HrynCo.NotificationService.DAL.Abstract/PushChannels/PushChannel.cs new file mode 100644 index 0000000..749e67b --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/PushChannels/PushChannel.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.DAL.Abstract/PushChannels/PushChannelWriteException.cs b/HrynCo.NotificationService.DAL.Abstract/PushChannels/PushChannelWriteException.cs new file mode 100644 index 0000000..dd3076c --- /dev/null +++ b/HrynCo.NotificationService.DAL.Abstract/PushChannels/PushChannelWriteException.cs @@ -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; +} diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs index a0647d6..8b8c01a 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs @@ -5,7 +5,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories; public interface IEmailChannelRepository { Task> GetAllAsync(CancellationToken ct = default); - Task> GetByServiceAsync(string serviceName, CancellationToken ct = default); + Task> GetByServiceAsync(Guid serviceId, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); Task> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default); Task AddAsync(EmailChannel channel, CancellationToken ct = default); diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs index 8b560f6..369cf47 100644 --- a/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs +++ b/HrynCo.NotificationService.DAL.EF/Configurations/EmailChannelEntityConfiguration.cs @@ -13,12 +13,10 @@ internal class EmailChannelEntityConfiguration : IEntityTypeConfiguration 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"); diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/PushChannelEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/PushChannelEntityConfiguration.cs new file mode 100644 index 0000000..11a15b2 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/PushChannelEntityConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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"); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Configurations/PushDeliveryEntityConfiguration.cs b/HrynCo.NotificationService.DAL.EF/Configurations/PushDeliveryEntityConfiguration.cs new file mode 100644 index 0000000..25b12cb --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Configurations/PushDeliveryEntityConfiguration.cs @@ -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 +{ + public void Configure(EntityTypeBuilder 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); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs index 7b95486..23f009f 100644 --- a/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs +++ b/HrynCo.NotificationService.DAL.EF/Entities/EmailChannelEntity.cs @@ -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; } diff --git a/HrynCo.NotificationService.DAL.EF/Entities/PushChannelEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/PushChannelEntity.cs new file mode 100644 index 0000000..d6870b9 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/PushChannelEntity.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.DAL.EF/Entities/PushDeliveryEntity.cs b/HrynCo.NotificationService.DAL.EF/Entities/PushDeliveryEntity.cs new file mode 100644 index 0000000..b39cf70 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Entities/PushDeliveryEntity.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261023223753_AddPushDeliveries.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261023223753_AddPushDeliveries.Designer.cs new file mode 100644 index 0000000..9b96d22 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261023223753_AddPushDeliveries.Designer.cs @@ -0,0 +1,402 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DailyLimit") + .HasColumnType("integer") + .HasColumnName("daily_limit"); + + b.Property("EmailChannelType") + .HasColumnType("integer") + .HasColumnName("provider_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MonthlyLimit") + .HasColumnType("integer") + .HasColumnName("monthly_limit"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServiceName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("service_name"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("SentCount") + .HasColumnType("integer") + .HasColumnName("sent_count"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("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("ServiceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("code"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("EmailTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("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 + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261023223753_AddPushDeliveries.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261023223753_AddPushDeliveries.cs new file mode 100644 index 0000000..239fba7 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261023223753_AddPushDeliveries.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + /// + public partial class AddPushDeliveries : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PushDeliveries", + columns: table => new + { + MessageId = table.Column(type: "uuid", nullable: false), + ServiceName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Error = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + InvalidRegistration = table.Column(type: "boolean", nullable: false), + CompletedAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PushDeliveries"); + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261024073202_LinkEmailChannelsToServices.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261024073202_LinkEmailChannelsToServices.Designer.cs new file mode 100644 index 0000000..6248c58 --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261024073202_LinkEmailChannelsToServices.Designer.cs @@ -0,0 +1,411 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DailyLimit") + .HasColumnType("integer") + .HasColumnName("daily_limit"); + + b.Property("EmailChannelType") + .HasColumnType("integer") + .HasColumnName("provider_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MonthlyLimit") + .HasColumnType("integer") + .HasColumnName("monthly_limit"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("SentCount") + .HasColumnType("integer") + .HasColumnName("sent_count"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("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("ServiceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("code"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("EmailTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("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 + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261024073202_LinkEmailChannelsToServices.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261024073202_LinkEmailChannelsToServices.cs new file mode 100644 index 0000000..8a36f8f --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261024073202_LinkEmailChannelsToServices.cs @@ -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" }); + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261024080457_AddPushChannels.Designer.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261024080457_AddPushChannels.Designer.cs new file mode 100644 index 0000000..2fbb04e --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261024080457_AddPushChannels.Designer.cs @@ -0,0 +1,464 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DailyLimit") + .HasColumnType("integer") + .HasColumnName("daily_limit"); + + b.Property("EmailChannelType") + .HasColumnType("integer") + .HasColumnName("provider_type"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("MonthlyLimit") + .HasColumnType("integer") + .HasColumnName("monthly_limit"); + + b.Property("Priority") + .HasColumnType("integer") + .HasColumnName("priority"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("settings"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Date") + .HasColumnType("date") + .HasColumnName("date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("provider_id"); + + b.Property("SentCount") + .HasColumnType("integer") + .HasColumnName("sent_count"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("DefinitionId") + .HasColumnType("uuid") + .HasColumnName("definition_id"); + + b.Property("HtmlBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("html_body"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("ServiceLanguageId") + .HasColumnType("uuid") + .HasColumnName("service_language_id"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("text") + .HasColumnName("subject"); + + b.Property("TextBody") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text_body"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CredentialsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("credentials"); + + b.Property("FirebaseProjectId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("firebase_project_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("ServiceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("name"); + + b.Property("Revision") + .IsConcurrencyToken() + .HasColumnType("uuid") + .HasColumnName("revision"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("code"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("EmailTemplateDefinitionEntityId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Name") + .IsRequired() + .HasJsonPropertyName("name"); + + b1.Property("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 + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/20261024080457_AddPushChannels.cs b/HrynCo.NotificationService.DAL.EF/Migrations/20261024080457_AddPushChannels.cs new file mode 100644 index 0000000..ec39e4c --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Migrations/20261024080457_AddPushChannels.cs @@ -0,0 +1,51 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace HrynCo.NotificationService.DAL.EF.Migrations +{ + /// + public partial class AddPushChannels : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "push_channels", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + service_id = table.Column(type: "uuid", nullable: false), + firebase_project_id = table.Column(type: "character varying(30)", maxLength: 30, nullable: false), + credentials = table.Column(type: "jsonb", nullable: false), + is_active = table.Column(type: "boolean", nullable: false), + created = table.Column(type: "timestamp with time zone", nullable: false), + updated = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "push_channels"); + } + } +} diff --git a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs index 52c206f..b028b4c 100644 --- a/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs +++ b/HrynCo.NotificationService.DAL.EF/Migrations/NotificationDbContextModelSnapshot.cs @@ -53,11 +53,9 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations .HasColumnType("integer") .HasColumnName("priority"); - b.Property("ServiceName") - .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)") - .HasColumnName("service_name"); + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("CredentialsJson") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("credentials"); + + b.Property("FirebaseProjectId") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasColumnName("firebase_project_id"); + + b.Property("IsActive") + .HasColumnType("boolean") + .HasColumnName("is_active"); + + b.Property("ServiceId") + .HasColumnType("uuid") + .HasColumnName("service_id"); + + b.Property("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("ServiceName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Error") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("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("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") diff --git a/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs b/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs index be060b3..f04bd28 100644 --- a/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs +++ b/HrynCo.NotificationService.DAL.EF/NotificationDbContext.cs @@ -11,6 +11,7 @@ public class NotificationDbContext : DbContext } internal DbSet ServiceLanguages => Set(); + internal DbSet PushChannels => Set(); internal DbSet Services => Set(); internal DbSet TemplateDefinitions => Set(); internal DbSet Templates => Set(); diff --git a/HrynCo.NotificationService.DAL.EF/NotificationDbContextFactory.cs b/HrynCo.NotificationService.DAL.EF/NotificationDbContextFactory.cs index 57a7f5e..0c4d2a5 100644 --- a/HrynCo.NotificationService.DAL.EF/NotificationDbContextFactory.cs +++ b/HrynCo.NotificationService.DAL.EF/NotificationDbContextFactory.cs @@ -8,9 +8,10 @@ internal sealed class NotificationDbContextFactory : IDesignTimeDbContextFactory public NotificationDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder() - .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); } -} \ No newline at end of file +} diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs index cca409c..bf9fb10 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs @@ -15,14 +15,14 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository> 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> GetByServiceAsync(string serviceName, CancellationToken ct = default) + public async Task> 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> 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 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 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); diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/PushChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/PushChannelRepository.cs new file mode 100644 index 0000000..ce82c0b --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/PushChannelRepository.cs @@ -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> GetAllAsync(CancellationToken ct = default) => + await ReadModels().OrderBy(c => c.ServiceName).ToListAsync(ct); + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) => + ReadModels().SingleOrDefaultAsync(c => c.Id == id, ct); + + public Task GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default) => + ReadModels().SingleOrDefaultAsync(c => c.ServiceId == serviceId && c.IsActive, ct); + + public Task 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 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 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 + }); +} diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/PushDeliveryRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/PushDeliveryRepository.cs new file mode 100644 index 0000000..8ca5f8d --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/Repositories/PushDeliveryRepository.cs @@ -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 DeliverOnceAsync(Guid messageId, string serviceName, + Func> 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().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().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; + } +} diff --git a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs index d849308..f08183d 100644 --- a/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs +++ b/HrynCo.NotificationService.DAL.EF/ServiceCollectionExtensions.cs @@ -17,6 +17,8 @@ public static class ServiceCollectionExtensions options.UseNpgsql(connectionString)); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/HrynCo.NotificationService.DAL.EF/update-development-database.bat b/HrynCo.NotificationService.DAL.EF/update-development-database.bat new file mode 100644 index 0000000..3f63a0d --- /dev/null +++ b/HrynCo.NotificationService.DAL.EF/update-development-database.bat @@ -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% diff --git a/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs b/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs index ade9233..7b70a77 100644 --- a/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs +++ b/HrynCo.NotificationService.Migrator/DevelopmentDataSeeder.cs @@ -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 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 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 diff --git a/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs b/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs index 6e2d115..874b200 100644 --- a/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs +++ b/HrynCo.NotificationService.Services.Tests/DevelopmentDataSeederTests.cs @@ -13,10 +13,16 @@ public sealed class DevelopmentDataSeederTests private readonly IEmailTemplateRepository _templates = Substitute.For(); private readonly INotificationServiceRepository _services = Substitute.For(); + public DevelopmentDataSeederTests() + { + _services.SaveAsync(Arg.Any(), true, Arg.Any()) + .Returns(call => { call.Arg().Id = Guid.NewGuid(); return Task.CompletedTask; }); + } + [Fact] public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate() { - _channels.GetByServiceAsync("TestService", Arg.Any()) + _channels.GetByServiceAsync(Arg.Any(), Arg.Any()) .Returns(Array.Empty()); _templates.GetAsync("TestService", "TestEmail", "en", Arg.Any()) .Returns((EmailTemplate?)null); @@ -39,10 +45,10 @@ public sealed class DevelopmentDataSeederTests [Fact] public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt() { - _channels.GetByServiceAsync("TestService", Arg.Any()) + _channels.GetByServiceAsync(Arg.Any(), Arg.Any()) .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" && diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailDeliveryValidatorTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailDeliveryValidatorTests.cs index c545e03..beb1e6c 100644 --- a/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailDeliveryValidatorTests.cs +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailDeliveryValidatorTests.cs @@ -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 ?? ""}'.", 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" }; diff --git a/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailServiceTests.cs b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailServiceTests.cs index cffb53d..5012716 100644 --- a/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailServiceTests.cs +++ b/HrynCo.NotificationService.Services.Tests/EmailProcessing/SendEmailServiceTests.cs @@ -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(); - channels.GetByServiceAsync("StoreMate-Prod", Arg.Any()) + channels.GetByServiceAsync(channel.ServiceId, Arg.Any()) .Returns([channel]); + var services = Substitute.For(); + services.GetByNameAsync("Invemory-Prod", Arg.Any()) + .Returns(new NotificationServiceInfo { Id = channel.ServiceId, Name = "Invemory-Prod" }); IEmailChannelUsageRepository usage = Substitute.For(); IEmailTemplateService templates = Substitute.For(); - templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any()) + templates.GetAsync("Invemory-Prod", "EmailVerification", "uk", Arg.Any()) .Returns(template); var renderer = new EmailTemplateRenderingService(); var smtp = new RecordingSmtpEmailSender(); INotificationResultPublisher resultPublisher = Substitute.For(); 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 { ["AppName"] = "StoreMate" } + Variables = new Dictionary { ["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( diff --git a/HrynCo.NotificationService.Services.Tests/Push/FcmPushSenderTests.cs b/HrynCo.NotificationService.Services.Tests/Push/FcmPushSenderTests.cs new file mode 100644 index 0000000..14d0704 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/Push/FcmPushSenderTests.cs @@ -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(); + var channels = Substitute.For(); + var client = Substitute.For(); + 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()).Returns(service); + channels.GetActiveForServiceAsync(service.Id, Arg.Any()).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()).Returns(json); + client.SendAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .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(), Arg.Any(), + Arg.Is(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task MissingServiceInactiveChannelOrInvalidCredentialsDoNotContactFirebase(bool exists, bool active) + { + var services = Substitute.For(); + var channels = Substitute.For(); + var client = Substitute.For(); + var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" }; + if (exists) services.GetByNameAsync(service.Name, Arg.Any()).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()).Returns(channel); + channels.GetCredentialsAsync(channel.Id, Arg.Any()).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) + }; +} diff --git a/HrynCo.NotificationService.Services.Tests/Push/PushChannelTestServiceTests.cs b/HrynCo.NotificationService.Services.Tests/Push/PushChannelTestServiceTests.cs new file mode 100644 index 0000000..5e944da --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/Push/PushChannelTestServiceTests.cs @@ -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(); + var firebase = Client(); + var id = Guid.NewGuid(); + channels.GetByIdAsync(id, Arg.Any()).Returns(new PushChannel + { Id = id, ServiceId = Guid.NewGuid(), FirebaseProjectId = "test-project", IsActive = false }); + channels.GetCredentialsAsync(id, Arg.Any()).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(), Arg.Any()); + await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(Arg.Any(), Arg.Any()); + await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default); + } + + [Fact] + public async Task UnsavedCredentialsOverrideSavedConfiguration() + { + var channels = Substitute.For(); + 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(), Arg.Any()); + } + + [Theory] + [InlineData("changed-project")] + [InlineData("invalid-json")] + [InlineData("missing-token")] + [InlineData("unknown-channel")] + public async Task InvalidInputNeverContactsFirebase(string scenario) + { + var channels = Substitute.For(); + var firebase = Client(); + var id = Guid.NewGuid(); + if (scenario != "unknown-channel") channels.GetByIdAsync(id, Arg.Any()).Returns(new PushChannel + { Id = id, ServiceId = Guid.NewGuid(), FirebaseProjectId = "test-project" }); + channels.GetCredentialsAsync(id, Arg.Any()).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(); + var firebase = Client(); + firebase.SendTestAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromException(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(); + firebase.CheckAuthorizationAsync(Arg.Any(), Arg.Any()) + .Returns(new PushDeliveryOutcome(null)); + firebase.SendTestAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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() }); + } +} diff --git a/HrynCo.NotificationService.Services.Tests/Push/PushDeliveryTests.cs b/HrynCo.NotificationService.Services.Tests/Push/PushDeliveryTests.cs new file mode 100644 index 0000000..6bf993a --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/Push/PushDeliveryTests.cs @@ -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(() => 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(); + var sender = Substitute.For(); + var publisher = Substitute.For(); + repository.DeliverOnceAsync(Arg.Any(), Arg.Any(), + Arg.Any>>(), Arg.Any()) + .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(x => x.Data.IsSuccess && x.Data.PushBindingId == message.Data.BindingId), default); + await sender.DidNotReceive().SendAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } +} diff --git a/HrynCo.NotificationService.Services.Tests/Push/SendPushDeliveryValidatorTests.cs b/HrynCo.NotificationService.Services.Tests/Push/SendPushDeliveryValidatorTests.cs new file mode 100644 index 0000000..0963d70 --- /dev/null +++ b/HrynCo.NotificationService.Services.Tests/Push/SendPushDeliveryValidatorTests.cs @@ -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)); + } +} diff --git a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelCommand.cs b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelCommand.cs index 1045c73..d424a22 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelCommand.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelCommand.cs @@ -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, diff --git a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs index ef3ff21..ff03793 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Create/CreateEmailChannelHandler.cs @@ -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> { private readonly IEmailChannelRepository _channels; + private readonly INotificationServiceRepository _services; public CreateEmailChannelHandler( IContextualSerilogLogger logger, - IEmailChannelRepository channels) + IEmailChannelRepository channels, + INotificationServiceRepository services) : base(logger) { _channels = channels; + _services = services; } protected override async Task> DoOnHandle( CreateEmailChannelCommand request, CancellationToken cancellationToken) { + if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null) + return Failure("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, diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs index e2dc157..d9d9710 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/GetByService/GetEmailChannelsHandler.cs @@ -10,19 +10,25 @@ internal sealed class GetEmailChannelsHandler : RequestHandler>> { private readonly IEmailChannelRepository _channels; + private readonly INotificationServiceRepository _services; public GetEmailChannelsHandler( IContextualSerilogLogger logger, - IEmailChannelRepository channels) + IEmailChannelRepository channels, + INotificationServiceRepository services) : base(logger) { _channels = channels; + _services = services; } protected override async Task>> 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>([]); + var channels = await _channels.GetByServiceAsync(service.Id, cancellationToken); return Success(channels); } } diff --git a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelCommand.cs b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelCommand.cs index 7f81669..f45b912 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelCommand.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelCommand.cs @@ -7,6 +7,7 @@ namespace HrynCo.NotificationService.Services.EmailChannels.Update; public sealed record UpdateEmailChannelCommand( Guid Id, + Guid ServiceId, int Priority, EmailChannelSettings Settings, int? DailyLimit, diff --git a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs index 0f4da77..1fbe2a7 100644 --- a/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs +++ b/HrynCo.NotificationService.Services/EmailChannels/Update/UpdateEmailChannelHandler.cs @@ -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> { private readonly IEmailChannelRepository _channels; + private readonly INotificationServiceRepository _services; public UpdateEmailChannelHandler( IContextualSerilogLogger logger, - IEmailChannelRepository channels) + IEmailChannelRepository channels, + INotificationServiceRepository services) : base(logger) { _channels = channels; + _services = services; } protected override async Task> DoOnHandle( @@ -27,6 +29,10 @@ internal sealed class UpdateEmailChannelHandler if (channel is null) return Failure("Email channel not found.", ServiceErrorCode.NotFound); + if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null) + return Failure("Select an existing service.", ServiceErrorCode.InvalidRequest); + + channel.ServiceId = request.ServiceId; channel.Priority = request.Priority; channel.Settings = request.Settings; channel.DailyLimit = request.DailyLimit; diff --git a/HrynCo.NotificationService.Services/PushChannels/SavePushChannelCommand.cs b/HrynCo.NotificationService.Services/PushChannels/SavePushChannelCommand.cs new file mode 100644 index 0000000..fb8b197 --- /dev/null +++ b/HrynCo.NotificationService.Services/PushChannels/SavePushChannelCommand.cs @@ -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> +{ + 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; } +} diff --git a/HrynCo.NotificationService.Services/PushChannels/SavePushChannelHandler.cs b/HrynCo.NotificationService.Services/PushChannels/SavePushChannelHandler.cs new file mode 100644 index 0000000..14fc5b1 --- /dev/null +++ b/HrynCo.NotificationService.Services/PushChannels/SavePushChannelHandler.cs @@ -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 logger, IPushChannelRepository channels) + : RequestHandler>(logger) +{ + protected override async Task> DoOnHandle(SavePushChannelCommand request, CancellationToken cancellationToken) + { + var existing = await channels.GetByIdAsync(request.Id, cancellationToken); + if (request.Id != Guid.Empty && existing is null) + return Failure("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("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 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> 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(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest); + } + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs index e39ccb6..4286012 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminDeletionTests.cs @@ -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()); diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs index 8be9409..04da4ef 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminNavigationTests.cs @@ -43,7 +43,7 @@ public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : ICla using var scope = context.App.Services.CreateScope(); await scope.ServiceProvider.GetRequiredService().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().SaveChangesAsync(); diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpEditorViewTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpEditorViewTests.cs index bb57edb..858db37 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpEditorViewTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpEditorViewTests.cs @@ -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 { diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpSaveTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpSaveTests.cs index 89081e6..a5fe547 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpSaveTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpSaveTests.cs @@ -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", diff --git a/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpTestControllerTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpTestControllerTests.cs index 856d8e1..e72f4a6 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpTestControllerTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/AdminSmtpTestControllerTests.cs @@ -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( diff --git a/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs index a57e2b8..e2c41e5 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelApiTests.cs @@ -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()).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("/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("/api/v1/email-channels?serviceName=IndependentChannelService"); + var filtered = await context.Client.GetFromJsonAsync("/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(); - 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().SaveChangesAsync(); - var all = (await context.Client.GetFromJsonAsync("/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(() => + scope.ServiceProvider.GetRequiredService().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("/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 }; diff --git a/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelServiceLinkTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelServiceLinkTests.cs new file mode 100644 index 0000000..ef07ad5 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelServiceLinkTests.cs @@ -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 +{ + [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(); + var service = (await services.GetAsync(context.ServiceId))!; + service.Name = "Invemory-Renamed"; + await services.SaveAsync(service, false); + } + var read = (await context.Client.GetFromJsonAsync($"/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("/api/v1/email-channels?serviceName=Invemory-Renamed"); + Assert.Single(current.GetProperty("data").EnumerateArray()); + var previous = await context.Client.GetFromJsonAsync("/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().SaveAsync(destination, true); + using var client = context.App.CreateClient(new() { AllowAutoRedirect = false }); + var html = await client.GetStringAsync($"/admin/channels/{channel.Id}"); + Assert.Contains("(); + 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(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(); + 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().GetByIdAsync(channel.Id))!; + Assert.Equal(context.ServiceId, saved.ServiceId); + Assert.Equal(2, saved.Priority); + Assert.Equal(25, saved.DailyLimit); + } + + private static async Task 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().AddAsync(channel); + await scope.ServiceProvider.GetRequiredService() + .IncrementUsageAsync(channel.Id, DateOnly.FromDateTime(DateTime.UtcNow)); + return channel; + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelServiceMigrationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelServiceMigrationTests.cs new file mode 100644 index 0000000..11f34ce --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/EmailChannelServiceMigrationTests.cs @@ -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 +{ + 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().GetAllAsync()); + var service = await scope.ServiceProvider.GetRequiredService().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(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().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(() => 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 PrepareAsync(string legacyName, string currentName) + { + var connectionString = await fixture.CreateDatabaseAsync(legacy: true); + await using (var db = TemplateDatabaseFixture.CreateContext(connectionString)) + await db.GetService().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 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(); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/InterimEmailTemplateMigrationTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/InterimEmailTemplateMigrationTests.cs index 0117b26..19e9ad3 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/InterimEmailTemplateMigrationTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/InterimEmailTemplateMigrationTests.cs @@ -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(); diff --git a/HrynCo.NotificationService.Web.IntegrationTests/PushChannelAdminTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/PushChannelAdminTests.cs new file mode 100644 index 0000000..85e3a5a --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/PushChannelAdminTests.cs @@ -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 +{ + [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(); + 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().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(); + var channel = Assert.Single(await channels.GetAllAsync()); + var other = new NotificationServiceInfo { Name = "Invemory-Push-Test" }; + await scope.ServiceProvider.GetRequiredService().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().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(); + 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(); + 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(); + 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(() => services.DeleteAsync(service.Id, service.Revision)); + } + + private static async Task 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)); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/PushChannelTestCredentials.cs b/HrynCo.NotificationService.Web.IntegrationTests/PushChannelTestCredentials.cs new file mode 100644 index 0000000..5122179 --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/PushChannelTestCredentials.cs @@ -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() + }); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/PushDeliveryRepositoryTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/PushDeliveryRepositoryTests.cs new file mode 100644 index 0000000..53595af --- /dev/null +++ b/HrynCo.NotificationService.Web.IntegrationTests/PushDeliveryRepositoryTests.cs @@ -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 +{ + [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(); + await Assert.ThrowsAsync(() => repository.DeliverOnceAsync(id, "Invemory", + _ => throw new InvalidOperationException("transient"), default)); + } + using (var retry = provider.CreateScope()) + { + var repository = retry.ServiceProvider.GetRequiredService(); + 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() + .DeliverOnceAsync(id, "Invemory", _ => throw new Exception("must not send twice"), default); + Assert.Equal("unregistered", saved.Error); + Assert.True(saved.InvalidRegistration); + } +} diff --git a/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs b/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs index 90af440..3e05984 100644 --- a/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs +++ b/HrynCo.NotificationService.Web.IntegrationTests/ServiceDeletionApiTests.cs @@ -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().SaveAsync(other, true); + var channels = reassignment.ServiceProvider.GetRequiredService(); + var channel = Assert.Single(await channels.GetAllAsync()); + channel.ServiceId = other.Id; + await channels.UpdateAsync(channel); + await reassignment.ServiceProvider.GetRequiredService().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("/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().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().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().SaveChangesAsync(); diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs index d7eb033..9521131 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs @@ -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 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 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 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); diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminPushChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminPushChannelsController.cs new file mode 100644 index 0000000..e4c959e --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminPushChannelsController.cs @@ -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 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 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 Index(CancellationToken ct) => View(await channels.GetAllAsync(ct)); + + [HttpGet("create")] + public Task Create(CancellationToken ct) => EditorViewAsync(new PushChannelEditViewModel(), ct); + + [HttpGet("{id:guid}")] + public async Task 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 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 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); + } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/TestChannelRequest.cs b/HrynCo.NotificationService.Web/Controllers/Admin/TestChannelRequest.cs new file mode 100644 index 0000000..5971993 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/TestChannelRequest.cs @@ -0,0 +1,3 @@ +namespace HrynCo.NotificationService.Web.Controllers.Admin; + +public sealed record TestChannelRequest(string ToEmail); diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs index da6685e..3a61f61 100644 --- a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs @@ -9,7 +9,9 @@ public class EmailChannelEditViewModel // ── Channel fields ───────────────────────────────────────────────── [Required] - public string ServiceName { get; set; } = ""; + public Guid? ServiceId { get; set; } + + public IReadOnlyList Services { get; set; } = []; public int Priority { get; set; } = 1; diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushChannelEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushChannelEditViewModel.cs new file mode 100644 index 0000000..49f19ea --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/PushChannelEditViewModel.cs @@ -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 Services { get; set; } = []; + public bool IsNew => Id == Guid.Empty; + public string PageTitle => IsNew ? "Create Push Channel" : "Edit Push Channel"; +} diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs index 748660b..449140a 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/CreateEmailChannelRequest.cs @@ -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, diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs index d086aec..6c32099 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelDto.cs @@ -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); diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs index 4a9355c..64ded4c 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelMapping.cs @@ -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, diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs index df858c3..04673e5 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/EmailChannelsController.cs @@ -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, diff --git a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs index 1081b1d..84e55a0 100644 --- a/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs +++ b/HrynCo.NotificationService.Web/Controllers/Api/EmailChannels/UpdateEmailChannelRequest.cs @@ -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, diff --git a/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.csproj b/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.csproj index b96102a..38b3c1f 100644 --- a/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.csproj +++ b/HrynCo.NotificationService.Web/HrynCo.NotificationService.Web.csproj @@ -23,6 +23,7 @@ + diff --git a/HrynCo.NotificationService.Web/Program.cs b/HrynCo.NotificationService.Web/Program.cs index 5563de1..52254ad 100644 --- a/HrynCo.NotificationService.Web/Program.cs +++ b/HrynCo.NotificationService.Web/Program.cs @@ -17,6 +17,9 @@ builder.Services.AddControllersWithViews() .AddRazorRuntimeCompilation(); builder.Services.AddNotificationDataAccess(appSettings.ConnectionString); builder.Services.AddNotificationServices(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); WebApplication app = builder.Build(); diff --git a/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml index ad8691b..b83c716 100644 --- a/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml @@ -7,6 +7,24 @@ ViewData["EditorTitle"] = Model.PageTitle; } + +
@Html.AntiForgeryToken() @@ -24,25 +42,26 @@ @* Channel Settings section *@
Channel Settings
-
- - - -
- -
-
+
+
+ + + +
+
-
+
-
+
@@ -50,18 +69,18 @@
-
-
+
+
-
+
-
+
@@ -71,26 +90,26 @@ @* SMTP Settings section *@
SMTP Settings
-
-
+
+
-
+
-
-
+
+
-
+
@@ -108,13 +127,13 @@
-
-
+
+ -
+
diff --git a/HrynCo.NotificationService.Web/Views/AdminPushChannels/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminPushChannels/Edit.cshtml new file mode 100644 index 0000000..fbbbf8a --- /dev/null +++ b/HrynCo.NotificationService.Web/Views/AdminPushChannels/Edit.cshtml @@ -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; +} + + + + + @Html.AntiForgeryToken() + + @if (!ViewData.ModelState.IsValid) + { +
+ } +
+
+ + + +
+
+ + + +
+
+
+ + +
+
+
+
+
+ + +
+ @if (Model.IsNew) + { + Required. Use the service account JSON for this Firebase project. + } + else + { + Credentials are saved. Leave blank to keep them, or paste a replacement. Changing the project requires new credentials. + } +
+ +
+ +
+
+
+
+ + + + +@section FormActions { + + + Cancel +} + + diff --git a/HrynCo.NotificationService.Web/Views/AdminPushChannels/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminPushChannels/Index.cshtml new file mode 100644 index 0000000..2273859 --- /dev/null +++ b/HrynCo.NotificationService.Web/Views/AdminPushChannels/Index.cshtml @@ -0,0 +1,40 @@ +@using HrynCo.NotificationService.DAL.Abstract.PushChannels +@model IReadOnlyList +@{ ViewData["Title"] = "Push Channels"; } + + + +@if (Model.Count == 0) +{ +
+
+ +

No push channels found.

+ Create First Channel +
+
+} +else +{ +
+
+ + + + @foreach (var channel in Model) + { + + + + + + + } + +
ServiceFirebase Project IDStatusActions
@channel.ServiceName@channel.FirebaseProjectId@(channel.IsActive ? "Active" : "Inactive")Edit
+
+
+} diff --git a/HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml b/HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml index d648426..f27e041 100644 --- a/HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminServices/DeleteService.cshtml @@ -14,7 +14,7 @@ else {
This permanently deletes the service and its language catalog.
} -

Email channels are retained. Their service names are independent and are not renamed or deleted.

+

Reassign this service's push channel and reassign or remove its email channels before deleting the service. Channels are never deleted automatically.

@Html.AntiForgeryToken() diff --git a/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml index 6b49c40..4dd6a79 100644 --- a/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminServices/Edit.cshtml @@ -15,7 +15,7 @@

Language codes

-

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.

+

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.

@for (var i = 0; i < Model.Languages.Count; i++) diff --git a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml index 381ca34..3158c1b 100644 --- a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml +++ b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml @@ -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); } @@ -34,6 +35,11 @@ Email Channels + diff --git a/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs b/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs index 04817d5..cf69b44 100644 --- a/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs +++ b/HrynCo.NotificationService.Worker.Services/EmailProcessing/SendEmailService.cs @@ -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 logger) { _channelRepository = channelRepository; + _services = services; _usageRepository = usageRepository; _templateService = templateService; _templateRenderingService = templateRenderingService; @@ -93,7 +96,9 @@ internal sealed class SendEmailService : ISendEmailService private async Task 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) diff --git a/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj b/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj index d2d358d..ab7265e 100644 --- a/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj +++ b/HrynCo.NotificationService.Worker.Services/HrynCo.NotificationService.Worker.Services.csproj @@ -9,6 +9,7 @@ + diff --git a/HrynCo.NotificationService.Worker.Services/Push/FcmPushSender.cs b/HrynCo.NotificationService.Worker.Services/Push/FcmPushSender.cs new file mode 100644 index 0000000..3bc5e05 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/FcmPushSender.cs @@ -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 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 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."); + } + } +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/FcmPushSettings.cs b/HrynCo.NotificationService.Worker.Services/Push/FcmPushSettings.cs new file mode 100644 index 0000000..0072c8f --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/FcmPushSettings.cs @@ -0,0 +1,6 @@ +namespace HrynCo.NotificationService.Worker.Services.Push; + +public sealed class FcmPushSettings +{ + public bool Enabled { get; set; } +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/FirebasePushClient.cs b/HrynCo.NotificationService.Worker.Services/Push/FirebasePushClient.cs new file mode 100644 index 0000000..5538efc --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/FirebasePushClient.cs @@ -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 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 + { + ["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 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 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 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.") + }; +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/IFirebasePushClient.cs b/HrynCo.NotificationService.Worker.Services/Push/IFirebasePushClient.cs new file mode 100644 index 0000000..56a79c9 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/IFirebasePushClient.cs @@ -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 CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken); + Task SendTestAsync(string token, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken); + Task SendAsync(Guid messageId, SendPushMessageData data, + FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken); +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/IPushSender.cs b/HrynCo.NotificationService.Worker.Services/Push/IPushSender.cs new file mode 100644 index 0000000..6704acd --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/IPushSender.cs @@ -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 SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken); +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/PushChannelTestService.cs b/HrynCo.NotificationService.Worker.Services/Push/PushChannelTestService.cs new file mode 100644 index 0000000..a58d812 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/PushChannelTestService.cs @@ -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 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 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 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); +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/PushChannelTestSettings.cs b/HrynCo.NotificationService.Worker.Services/Push/PushChannelTestSettings.cs new file mode 100644 index 0000000..bfc658c --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/PushChannelTestSettings.cs @@ -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; } +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/PushDeliveryMetrics.cs b/HrynCo.NotificationService.Worker.Services/Push/PushDeliveryMetrics.cs new file mode 100644 index 0000000..146ebeb --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/PushDeliveryMetrics.cs @@ -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 Outcomes = Meter.CreateCounter("push.provider.outcomes"); + private static readonly Counter Retries = Meter.CreateCounter("push.provider.retries"); + + public static void RecordOutcome(bool succeeded) => + Outcomes.Add(1, new KeyValuePair("outcome", succeeded ? "accepted" : "failed")); + + public static void RecordRetry() => Retries.Add(1); +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/PushDeliveryService.cs b/HrynCo.NotificationService.Worker.Services/Push/PushDeliveryService.cs new file mode 100644 index 0000000..956d9ca --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/PushDeliveryService.cs @@ -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 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 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); + } +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/PushMessageValidator.cs b/HrynCo.NotificationService.Worker.Services/Push/PushMessageValidator.cs new file mode 100644 index 0000000..2f7ff2c --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/PushMessageValidator.cs @@ -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); + } + +} diff --git a/HrynCo.NotificationService.Worker.Services/Push/PushRetryException.cs b/HrynCo.NotificationService.Worker.Services/Push/PushRetryException.cs new file mode 100644 index 0000000..3e86b81 --- /dev/null +++ b/HrynCo.NotificationService.Worker.Services/Push/PushRetryException.cs @@ -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; +} diff --git a/HrynCo.NotificationService.Worker/Program.cs b/HrynCo.NotificationService.Worker/Program.cs index d9de6c9..2c45da7 100644 --- a/HrynCo.NotificationService.Worker/Program.cs +++ b/HrynCo.NotificationService.Worker/Program.cs @@ -20,5 +20,18 @@ builder.Services.Configure( builder.Services.AddHostedService(); +var pushSettings = builder.Configuration.GetSection("FcmPush") + .Get() ?? new(); +builder.Services.AddSingleton(pushSettings); +if (pushSettings.Enabled) +{ + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddHostedService(); +} + var host = builder.Build(); host.Run(); diff --git a/HrynCo.NotificationService.Worker/SendEmailConsumer.cs b/HrynCo.NotificationService.Worker/SendEmailConsumer.cs index 9bd8793..2d03f35 100644 --- a/HrynCo.NotificationService.Worker/SendEmailConsumer.cs +++ b/HrynCo.NotificationService.Worker/SendEmailConsumer.cs @@ -8,7 +8,6 @@ using Microsoft.Extensions.Options; public sealed class SendEmailConsumer : RabbitMqConsumerBase { internal const string IncomingQueue = "notification.send-email"; - internal const string SupportedMessageType = "Notification.SendEmail.v1"; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; @@ -33,7 +32,7 @@ public sealed class SendEmailConsumer : RabbitMqConsumerBase"}'."; try diff --git a/HrynCo.NotificationService.Worker/SendPushConsumer.cs b/HrynCo.NotificationService.Worker/SendPushConsumer.cs new file mode 100644 index 0000000..df94ab0 --- /dev/null +++ b/HrynCo.NotificationService.Worker/SendPushConsumer.cs @@ -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 options, IServiceScopeFactory scopes, + ILogger logger) + : RabbitMqConsumerBase(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() + .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() + .CompleteFailureAsync(Guid.Parse(context.MessageId!), message, cancellationToken); + } +} diff --git a/HrynCo.NotificationService.Worker/SendPushDeliveryValidator.cs b/HrynCo.NotificationService.Worker/SendPushDeliveryValidator.cs new file mode 100644 index 0000000..43fb332 --- /dev/null +++ b/HrynCo.NotificationService.Worker/SendPushDeliveryValidator.cs @@ -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); +} diff --git a/HrynCo.NotificationService.Worker/SupportedMessageTypeParser.cs b/HrynCo.NotificationService.Worker/SupportedMessageTypeParser.cs new file mode 100644 index 0000000..6ede233 --- /dev/null +++ b/HrynCo.NotificationService.Worker/SupportedMessageTypeParser.cs @@ -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 + }; +} diff --git a/README.md b/README.md index 2da43d5..9ed94a2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/android-push.md b/docs/android-push.md new file mode 100644 index 0000000..25d8655 --- /dev/null +++ b/docs/android-push.md @@ -0,0 +1,168 @@ +# Push channels and Android FCM — IT-1074 + +Notification Service owns Firebase configuration. Invemory owns installation/user bindings +and notification preferences; it publishes a push request without Firebase credentials or OAuth access tokens. +The public Contracts package must be delivered first; updating Invemory is a separate step. + +## Administration + +The **Push Channels** menu opens /admin/push-channels. Create/Edit use the existing service +catalog (ServiceId UUID), Firebase Project ID, service account JSON and Active. Fields use +compact desktop widths and wrap on small screens. A service has one push channel, enforced +by a unique ServiceId index. There is no priority/fallback or extra routing configuration. +Inactive channels are retained but never selected for sending. Reassignment is allowed; +service rename automatically updates the displayed name without breaking the FK. + +Creating a channel requires a valid service-account JSON whose project_id matches the +Firebase Project ID. The server validates its type, email and RSA private key without +contacting Google. Editing with an empty credentials field preserves the stored value; +changing the project requires replacement credentials. An inactive channel also requires +valid credentials, so activation does not introduce a partly configured channel. + +Safe list/edit models contain no secret. Edit does not load credentials into the browser, +and validation failures clear both the model and attempted ModelState value before rendering. +Credential parser/provider errors use fixed messages and never log the JSON/private key. +The Worker constructs Google credentials from the validated service-account email/private +key; supplied token_uri or other endpoint fields are not used. There is no ADC fallback. + +Storage follows the existing SMTP approach: credentials are server-only JSONB in the +Notification Service database, without new application-level encryption. Restrict access +to this private administration app, its database and backups. Existing private-network +access assumptions are unchanged; this feature adds no public administration endpoint. + +## Persistence and migration + +Apply 20261024080457_AddPushChannels before opening Push Channels or enabling delivery. +The additive migration creates push_channels with a required services.id FK using RESTRICT +and unique service_id index. It does not alter existing email channels, settings or usage. +Service deletion is blocked while either email or push channels still reference it; reassign +a push channel before deleting its service. Saves reuse the service administration transaction +lock to serialize service membership and duplicate-channel checks. Migration downgrade drops +push channels and their credentials, so it is destructive after channels have been configured. + +The existing DAL.EF/update-development-database.bat applies the schema to the database from +the Web Development configuration; it does not provision containers or restart Web/Worker. + +## Worker and contracts + +Set FcmPush__Enabled=true to register the push consumer. Firebase project/credentials and +service routing come from the active channel, not environment settings or producer payloads. +The previous draft FcmPush__ServiceName host filter is removed: each request's exact current +ServiceName resolves to services.id and then that service's active push channel. Missing +services, missing/inactive channels and invalid stored configuration produce safe terminal +failure results without contacting Firebase. No legacy service-name alias is used at runtime. + +SendPushMessageData retains registration/binding/user identity, ServiceName, device Token +and TokenHash, title/body, version, /home route and ExpiresAt. FcmProjectId, FcmAccessToken and +FcmAccessTokenExpiresAt have been removed. SupportedMessageType remains public in Contracts; +the existing Notification.SendPush.v1 header and notification.send-push queue are unchanged. +OAuth tokens are obtained by the Firebase SDK inside Notification Service from the channel's +service account. They are never included in RabbitMQ request/result contracts. + +This replaces the unpublished draft that obtained OAuth authorization in Invemory. Source +clients still initializing the removed properties must update after the new Contracts package +is explicitly published. Old payloads containing additional JSON fields are not a configuration +source; the server ignores those fields and still requires its own active channel. Do not +resume producers that send Firebase secrets until that separate Invemory update removes them. +No Invemory source or production configuration is changed by this task. + +The Worker sends bounded version-1 data-only messages with /home routes and a maximum +15-minute expiry, preserving Android binding checks before display. Delivery outcomes are +persisted by service/message ID under an advisory lock and replayed after reply failures. +A crash after FCM acceptance but before DB commit may still repeat delivery; this is not +exactly-once delivery. Expired messages are not sent. + +UNREGISTERED invalidates only the matching registration/binding/token-hash. Sender mismatch, +invalid argument and authorization/configuration failures do not invalidate device tokens. +Transient delivery uses the existing three attempts, exponential delay, jitter and Retry-After. +Outcomes expire after 30 days; existing outcome/retry metrics contain no target/token labels. +Firebase acceptance does not guarantee Android display (force-stop, Doze and connectivity apply). + +## Validation boundaries + +Tests cover the administration lifecycle, secret non-disclosure, unchanged-secret edits, +service reassignment/rename, inactive selection, duplicate/missing services, project changes, +worker name-to-ID resolution, server credential selection and credential-free Contracts. +All provider routing tests use a fake Firebase client. Real Firebase delivery, credential +permissions and Android notification display require a separate end-to-end verification +with an authorized configured project/device; fake local credentials cannot prove those. + +On 2026-10-24 the Release solution build and targeted tests passed (21 unit and +24 integration). Rider inspection reported no errors in the changed production classes; +the existing Firebase SDK Token deprecation warning remains. Structural review kept +changed methods within cognitive complexity 10; the tool did not report numeric scores. +The review compared email-channel mapping/editor patterns, service catalog controllers, +transaction locking and existing push delivery/retry behavior. No unresolved blocking +findings remained for the implemented scope. + +Desktop browser validation used a disposable database and Web at 127.0.0.1:52658, with +an offline-generated test service account (no Google account or network send): empty +list, create, invalid JSON feedback/secret clearing, edit without secret disclosure, and +Active off with credentials preserved all passed. Mobile wrapping was inspected in CSS +but not exercised on an Android device. The temporary Web and database container were +removed after validation. The owner-applied AddPushChannels migration was separately +confirmed in the existing development database; it was not reapplied. + +## Channel tests — 2026-10-24 + +The editor has separate **Check Firebase authorization** and **Send test push** actions, +following the current-form SMTP test workflow. Neither action saves a channel. New channels +use the entered service-account JSON; blank JSON on an existing channel uses its stored +credentials only if the project is unchanged. Inactive channels can be tested explicitly. +Both POST endpoints require the form antiforgery token and bounded request bodies. +The existing private-network/port-forward access boundary is retained: this Web application +has no in-app authentication or administrator-role middleware. Do not expose it publicly. + +Authorization requests an OAuth access token with the firebase.messaging scope and a +30-second timeout. It neither requires a recipient nor calls FCM send. A successful OAuth +exchange proves service-account authentication only, not FCM API enablement, send permission +or device delivery. The access token is never returned, saved or logged. + +Send opens a separate Recipient token field and sends a neutral notification message through +the same FirebasePushClient transport using current form credentials. RecipientToken is not +part of the save model or channel storage. The dialog clears it after the attempt or close. +Only test on an explicitly supplied, owned test-device token. Provider acceptance is shown +as acceptance, never as verified delivery/display. This direct provider diagnostic does not +exercise Invemory's data-only binding, expiry/tap checks, transactional Outbox or result queue; +it does not replace that separate end-to-end validation. Foreground display depends on the +receiving app. No user inventory or account data is included. + +Errors are fixed, safe messages; no provider exception, credentials, access token or recipient +is echoed. The UI uses textContent for results. Saved credentials never populate the editor. + +### Channel-test verification + +Release solution build passed. 29 focused push unit tests and 9 PushChannelAdmin integration +tests passed, including CSRF rejection, missing credentials, unchanged/changed project reuse, +unsaved credentials, separate authorization/send calls, transient errors and no persistence. +Rider's final changed-file error inspection was clean. Existing FirebaseAdmin Message.Token +obsolescence and Microsoft.OpenApi vulnerability warnings remain outside this task. +Cognitive complexity was manually reviewed, not numerically measured. + +A temporary local Web instance on 127.0.0.1:52658 verified both buttons, modal recipient input, +empty-token validation, missing-credential response and clearing the recipient after a failed +attempt. Only a fake non-deliverable token with missing credentials was submitted; the +request stopped before Firebase. No channel was created or modified by this UI verification. +Code review compared current SMTP test behavior, existing credential parser/provider, +Razor editor layout, antiforgery and repository interfaces. Fixed script initialization to +wait until footer buttons exist and rejected malformed/oversized test requests safely. +The final review found no further actionable issue in these two actions. + +The owner subsequently configured the local channel, verified OAuth authorization and sent +a real test to the current Samsung token on 2026-10-24. The owner confirmed notification +display and opening the installed application on tap. This validates the direct Firebase +notification test, not the Invemory data-only/outbox flow. The installed receiver intentionally +validates data-only binding fields; a notification-only test received while Invemory is in +the foreground does not satisfy that contract. Background notification display is handled +by the Firebase SDK. No additional diagnostic messages were sent by the agent. + +The authorization button and its result are directly beneath the service-account JSON +field. The recipient input and Send test push button remain in the separate test dialog. + +Delivery verification: the full Release solution build and all 221 tests passed (67 unit, +154 integration). The pre-existing Microsoft.OpenApi 2.0.0 vulnerability warning remains. +Delivery is restricted to Notification Service; Invemory producer/client migration and its +runtime validation remain separate pending work. TeamCity main triggers include general +checks, image publication, Contracts publication and production deployment. The owner +explicitly authorized this automatic production consequence for the Notification Service +delivery on 2026-10-24. It does not authorize delivery of the remaining Invemory repositories. diff --git a/docs/email-channel-service-link.md b/docs/email-channel-service-link.md new file mode 100644 index 0000000..b06f757 --- /dev/null +++ b/docs/email-channel-service-link.md @@ -0,0 +1,79 @@ +# Email channels: stable service association + +Email channels reference `services.id` (UUID) through required `email_channels.service_id`. +The `(service_id, priority)` index supports channel selection; the service FK uses RESTRICT, +so service deletion cannot remove channels or their usage. Service names in channel reads +come from the related service, not a copied string. Create/Edit select an existing service; +Edit can reassign a channel without changing its identity or statistics. + +## Migration + +`LinkEmailChannelsToServices` validates every legacy channel name before replacing the +string column. An exact, case-sensitive service-name match is accepted. The owner-approved +legacy mapping `StoreMate-Prod` → `Invemory-Prod` is also accepted; the existing staging +name `Invemory.Staging.Sandbox` uses an exact match. Every channel must have exactly one +candidate service. Missing names or ambiguity (for example both legacy and renamed +services existing) abort the migration. No service is guessed, created, renamed or deleted. + +The migration runs in an EF transaction and locks services/channels while checking and +backfilling. Failure rolls back schema and data. Channel IDs, SMTP settings (including +secrets and sender settings), priority, quotas, warning threshold, active state, timestamps +and usage records remain unchanged. No SMTP settings are logged by the migration. +Downgrade reconstructs the current service names; historical names are not restored. + +Before a separately authorized rollout, audit unmatched/ambiguous names and resolve them +with the owner, take the normal backup, and coordinate Web/Worker downtime with migration: +old binaries use the removed `service_name` column. Administrative POST/PUT clients must +send `serviceId`; name-only writes are no longer supported. RabbitMQ producers retain +their existing `ServiceName` wire field and must send the current service name. The +approved legacy mapping applies only during migration, never as a runtime fallback. + +## Verification + +### Local database update on Windows + +Run from any directory, without arguments or environment setup: + +```bat +C:\projects\HrynCo\hrynco-notification-service\HrynCo.NotificationService.DAL.EF\update-development-database.bat +``` + +The script resolves its directory through %~dp0, reads the existing Web +`appsettings.Development.json`, builds the EF project in Release and applies pending +migrations. It requires dotnet-ef and Windows PowerShell. It validates a loopback host and a nonempty database name before invoking EF. +The port and database come from the Development configuration without hardcoded overrides. +A remote host is rejected; this script is specifically for the local development database. It never installs, recreates or restarts containers. + +The connection is passed only through the child process's `App__ConnectionString`; +the design-time factory reads it. Credentials are neither echoed nor passed as CLI +arguments, and inherited connection overrides are replaced with the verified Development +configuration. The script returns EF's exit code (or a nonzero code for configuration +failure). Restart local Web/Worker after a schema change to use matching binaries. + +### Tests + +PostgreSQL integration tests exercise exact and approved-alias backfill, preserved channel +settings/limits/usage, downgrade, missing/ambiguous-name rollback, FK enforcement, service +rename, API validation, editable reassignment with a blank password, and service deletion +after reassignment. Worker tests verify name-to-ID channel resolution and sending/usage. +All migration rehearsals use disposable local databases; they do not alter production. + +On 2026-10-24, the Release solution build, 54 service/worker tests and 145 integration +tests passed. A follow-up check explicitly verified both invalid update IDs reach the +service-existence validator (2/2 passed). Rider inspected the changed production methods; +remaining findings were existing/style suggestions. Cognitive complexity was reviewed +structurally against the limit of 10; no numeric score was produced by the inspection. + +After separate owner authorization, the existing local container database was backed up +and updated with the already-built Migrator. Its two pending migrations were +`20261023223753_AddPushDeliveries` and `20261024073202_LinkEmailChannelsToServices`. +No email channels or usage records existed in this local database before the update; +their unchanged empty state and the installed RESTRICT FK were verified afterwards. +The development batch script was then executed from its own folder and another working +directory: both returned 0 and reported no pending migrations. An isolated fake EF +failure verified exit-code propagation (37). The current local Web at port 5188 rendered +the dropdown with the existing service catalog. Disposable test containers were removed. +Production was not accessed or migrated. + +The existing private-network administration access model is unchanged. This change does +not configure Firebase or resume the paused push-channel architecture. diff --git a/docs/email-template-definition-api.md b/docs/email-template-definition-api.md index 67dbad8..6d8128e 100644 --- a/docs/email-template-definition-api.md +++ b/docs/email-template-definition-api.md @@ -21,7 +21,7 @@ A variable is `{ "name": "Name", "required": true }`. A translation contains `id `POST /api/v1/services` accepts `{ "name": "ExampleService", "languages": [{ "code": "en" }, { "code": "uk" }] }`. It returns 201 with a Location to the service read and the complete service DTO. Names are exact and unique. The required languages array may be empty; new services cannot reference persisted language IDs. -`PUT /api/v1/services/{serviceId}` accepts the last-read nonempty `revision`, `name`, and a required full `languages` array. Retain each existing language's ID from the read DTO; omit/empty the ID for a new code. The returned service DTO includes the new revision and generated language IDs. Omitted unused catalog rows are removed; omitting a used language returns 409 with no changes. A stale revision returns 409; a missing service returns 404. Existing migrated nonstandard codes remain valid unchanged, and the existing stricter syntax validation applies only to added/renamed codes. Renames do not rewrite independently configured channel service names. +`PUT /api/v1/services/{serviceId}` accepts the last-read nonempty `revision`, `name`, and a required full `languages` array. Retain each existing language's ID from the read DTO; omit/empty the ID for a new code. The returned service DTO includes the new revision and generated language IDs. Omitted unused catalog rows are removed; omitting a used language returns 409 with no changes. A stale revision returns 409; a missing service returns 404. Existing migrated nonstandard codes remain valid unchanged, and the existing stricter syntax validation applies only to added/renamed codes. Email channels reference the stable service ID; their displayed service name updates immediately after a rename. Publishers must use the current name. `DELETE /api/v1/services/{serviceId}/languages/{languageId}?revision={serviceRevision}` removes an unused catalog row only. Used languages return 409; use the explicit destructive endpoint when the versions should be deleted: @@ -40,7 +40,7 @@ All three body properties are required. The nonnegative count and service revisi - `DELETE /api/v1/services/{serviceId}/email-template-definitions?key={encodedKey}&revision={lastReadDefinitionRevision}` removes the selected definition and all of its versions atomically. - `DELETE /api/v1/services/{serviceId}?revision={lastReadServiceRevision}` requires **every definition** to have been removed first, including zero-version definitions; otherwise it returns 409. It removes the remaining catalog languages and service atomically. -Both endpoints return HTTP 200 with the existing `ApiResponse` success envelope (`data: {}`). A missing resource returns 404, missing/empty/malformed revision or invalid key returns 400, and stale revision returns 409. Every check and mutation runs under the common transaction advisory lock. Concurrent definition creation cannot race past the service's nonempty check. These operations do not remove email channels, whose service name is a separate identifier without a service foreign key. +Both endpoints return HTTP 200 with the existing `ApiResponse` success envelope (`data: {}`). A missing resource returns 404, missing/empty/malformed revision or invalid key returns 400, and stale revision returns 409. Every check and mutation runs under the common transaction advisory lock. Concurrent definition creation cannot race past the service's nonempty check. These operations do not remove email channels. A service with linked channels returns 409; reassign or explicitly remove its channels before deleting it. The foreign key restricts deletion. ## Atomic upsert @@ -77,10 +77,12 @@ In the admin channel editor, **Send Test Email** tests the form's current values Saving an existing channel follows the same password rule: a blank password keeps the saved value when host, port, username, and SSL/TLS mode are unchanged, so sender name/address and limit edits do not require re-entering it. A changed SMTP connection needs an explicitly entered password. Creating a channel in the admin editor still requires a password. Validation errors redisplay the editor without rendering the saved or submitted password. -- `GET /api/v1/email-channels/all` enumerates **all** channels, including channels whose exact `serviceName` has no service row. +- `GET /api/v1/email-channels/all` enumerates all channels, each linked to an existing service by its UUID `serviceId`. Read DTOs include `serviceId` and the current `serviceName`. - Existing `GET /api/v1/email-channels?serviceName={exactName}`, GET by ID, POST, PUT by ID and DELETE by ID remain available. Create returns 201/Location and its existing GUID response; update/delete return 200/Unit. - Create supports SMTP (`channelType: 1`). Request/response `settings` explicitly maps `host`, `port`, `username`, `password`, `useSsl`, `fromEmail`, and `fromName`; reads no longer lose derived SMTP fields through an abstract JSON type. Host/fromEmail and a valid port are required. The optional empty username/password support unauthenticated development SMTP. -- Existing PUT behavior is retained: it changes settings, priority, limits, warning threshold and active state, **not** serviceName or channel type. Service names have no service FK and no rename cascade. Read all channels separately when performing an explicitly authorized cleanup; deleting a service does not delete its channels. +- POST and PUT require a nonempty `serviceId` belonging to an existing service; a missing, empty or unknown ID returns 400/InvalidRequest. A raw `serviceName` cannot create or reassign a channel. PUT changes service assignment, settings, priority, limits, warning threshold and active state, retaining channel ID/type and usage. Create/Edit offer the same existing-services dropdown, including editable assignment on Edit; validation errors reload it. A blank password on reassignment preserves the saved value if the SMTP connection is unchanged. +- RabbitMQ `SendEmailMessageData.ServiceName` is unchanged. The worker resolves the exact current name to a service UUID and chooses active channels by that ID and priority. The filtered GET resolves the same way (an unknown name returns an empty list). The migration-only legacy alias is not a runtime alias. +- See [email channel service migration](email-channel-service-link.md) for backfill, failure handling and rollout compatibility. - SMTP HTTP lifecycle tests cover create/read/update/filter/all/delete. The repository ID lookup is a no-tracking domain projection so an update can attach its mapped entity without an EF tracking conflict. No SMTP send or owner-channel cleanup is implied by these tests. ## Responses @@ -111,3 +113,6 @@ The revision is an optimistic-concurrency token, not a historical snapshot or re Disposable PostgreSQL integration tests cover service lookup/rename, whole-definition reads, create/update, required arrays/boolean presence, exact codes, explicit removals, empty definitions, retention of omitted versions and their IDs/content/timestamps, shared contract replacement, invalid-save atomicity, duplicate/stale conflicts, concurrent requests, encoded keys and creation-Location round trips, and legacy-write revision invalidation. The full existing suite remains the regression gate for administration, migrations and the worker. Separate local HTTP checks exercise the host Web against task-owned infrastructure; no owner database or running Rider environment is used. Deployment/tunnel/Kubernetes validation is not implied by these tests. On 2026-10-17 the solution build and all 148 tests passed after the REST CRUD, 201/Location and administration follow-ups (32 service/worker tests and 116 PostgreSQL integration tests, including 29 new definition-API cases and 3 administration-navigation checks). A separate host Web at `127.0.0.1:52656`, backed by the task-owned PostgreSQL container on port `58519`, passed service reads, exact encoded-key round trips (slash/literal `%2F`/Unicode), 201-created Location round trips and 200-updates, omitted-version retention, duplicate/stale conflicts, required-boolean rejection with no changes, final-version removal retaining the definition, required-key errors, and exact-name case checks. Its OpenAPI document exposed the typed response schemas and required request fields. The task Web and disposable database were stopped/removed afterward. No owner database, containers or Rider process were modified. Existing Microsoft.OpenApi NU1903 dependency warnings remain. + +Push channels also reference the service by ID and block service deletion while linked. +Reassign the push channel first; see [Push Channels](android-push.md). diff --git a/docs/email-template-localizations.md b/docs/email-template-localizations.md index 3e8e6c3..b7ad563 100644 --- a/docs/email-template-localizations.md +++ b/docs/email-template-localizations.md @@ -17,7 +17,7 @@ Codes are case-sensitive exact identifiers. `ru`, `ru-RU`, `en` and `EN` remain The dark navbar includes **API documentation** at the right, opening `/scalar/v1` in a new tab with an explicit accessible new-tab label and safe opener attributes. All four administration data tables (Services, service templates, Email Channels and embedded Service Languages) explicitly use the shared `admin-data-table` style: contrasting blue headers, outer borders, vertical column separators and vertically centered column headings. Vendor tables are not globally overridden. The template list retains its white filter controls and compact Action column; channel actions and the existing responsive table wrappers remain available. Navbar items and filter controls are vertically centered. When a data-table control receives keyboard focus, only its nearest responsive wrapper's horizontal scroll position is adjusted to expose the full control and focus ring, including newly added language rows. This helper never scrolls the document or vertical ancestors and does not run for pointer-originated focus. Long service names wrap inside list cells and the selected-service heading/breadcrumb rather than widening the page. -`/admin/services` lists services. Its create/edit form manages Name and an inline table of language codes. Successful creation or editing, including a rename, returns to `/admin/services/{serviceId}` and its template list; validation/save errors retain the service form and entered values. The service page's **Edit Service** action uses the primary blue outline style. There is no separate language editor. Existing code rows retain their IDs when renamed. A transaction-local staging step frees old codes before final assignment so valid code swaps and replacements respect uniqueness without changing version identities. Intermediate codes are never committed or visible to other transactions. Renaming a service or code changes the identifiers used by template API clients and senders; publishers and independently configured email-channel service names must be aligned by the administrator. +`/admin/services` lists services. Its create/edit form manages Name and an inline table of language codes. Successful creation or editing, including a rename, returns to `/admin/services/{serviceId}` and its template list; validation/save errors retain the service form and entered values. The service page's **Edit Service** action uses the primary blue outline style. There is no separate language editor. Existing code rows retain their IDs when renamed. A transaction-local staging step frees old codes before final assignment so valid code swaps and replacements respect uniqueness without changing version identities. Intermediate codes are never committed or visible to other transactions. Renaming a service or code changes the identifiers used by template API clients and senders; publishers must use the current names. Email channels keep their stable service-ID association and display the renamed service automatically. Ordinary removal of an unused language row is saved with the service. For a used language, the form displays its affected version count and blocks ordinary removal. The server repeats this check using the current database count. After a validation or save error, usage counts are reloaded by persisted language ID while preserving entered names/codes and the original revision. If counts cannot be loaded, their display is unavailable and ordinary removal stays disabled. @@ -37,7 +37,7 @@ Service and template editor breadcrumbs appear above the editor card, followed b Preview uses the selected version and sample values from the shared contract. HTML variables are encoded as in the worker, while subject and text stay plain. The owner approved `sandbox="allow-same-origin"` without `allow-scripts` for automatic HTML-preview height on 2026-10-17. This trades the prior opaque origin for trusted parent-side DOM measurement; it does not permit template scripts. The existing restrictive CSP remains unchanged. Scripts, forms, top/pop-up navigation and external resources stay blocked; the trusted parent also prevents preview link navigation and submits. No JavaScript is inserted into the template HTML. On load, content/data-image/width changes and viewport resize, the parent measures content height; resetting height before measurement lets short/empty content shrink. Old ResizeObservers are disconnected when the document changes or page hides. Save errors preserve entered content and active language; navigation warns about unsaved edits. -The Services and scoped Templates lists offer compact Edit/Delete links. Their separate confirmation pages require antiforgery-protected POST and explicit ConfirmDelete. Definition deletion shows its selected service, key, current translation count and exact codes; it deletes the definition/shared contract/versions while retaining the service/catalog/channels. Service deletion is blocked while any definition exists, including empty definitions, and explicitly retains channels. Revision conflicts reload the current count/revision, clear the checkbox, and require fresh confirmation; definition cancel/success retain the key filter. These routes reuse the revision-protected REST commands and validate selected-service ownership. +The Services and scoped Templates lists offer compact Edit/Delete links. Their separate confirmation pages require antiforgery-protected POST and explicit ConfirmDelete. Definition deletion shows its selected service, key, current translation count and exact codes; it deletes the definition/shared contract/versions while retaining the service/catalog/channels. Service deletion is blocked while any definition (including an empty definition) or email channel references the service. Reassign or explicitly remove channels first; deletion never cascades to them. Revision conflicts reload the current count/revision, clear the checkbox, and require fresh confirmation; definition cancel/success retain the key filter. These routes reuse the revision-protected REST commands and validate selected-service ownership. Empty Services uses the existing administration card/empty-state pattern with Create First Service. An empty selected service keeps its table/filter header and offers Create First Template; a filter miss instead offers Clear filter. Template load failures remain errors and never claim an empty catalog. The Email Channels empty-state view is unchanged. @@ -108,3 +108,6 @@ The service-scoped navigation and all-language tab revision was validated separa The later administration styling pass checked all four `admin-data-table` views at 1440, 390 and 320 pixels using temporary Web `127.0.0.1:52656` and task-owned PostgreSQL `58519`. Shared header colors/borders/vertical alignment, long service-name wrapping, filter-miss headers, compact actions, and table-local scrolling passed without page overflow. Keyboard focus exposed the complete channel/template/language actions and newly added language rows. The navbar remained 52px tall with matching brand/link centers, and API documentation opened a rendered Scalar page in a separate tab with a null opener; link contrast was 14.63:1. The full solution build and 148 tests passed (32 service/worker and 116 integration tests). Temporary resources were removed afterward. Remove behavior and the separate DeleteLanguage confirmation page were retained; no modal UX was introduced. The final preview/deletion browser pass used the same task-owned host and database. At 1440px, long content grew to 1047px and an empty language tab shrank to 80px; at 390px the corresponding measurements were 997px and 80px. At 320px responsive text/data-image content grew from 1206px to 1653px when the image height changed, with document width remaining 320px. Preview script execution stayed blocked, and parent-side event checks cancelled top/self links and form submission while retaining about:srcdoc. At 320px the definition confirmation rejected an unchecked submission, then confirmed deletion returned to the scoped empty-template state; nonempty service deletion was blocked, and confirmed empty-service deletion reached the empty Services state. The table header/filter and first-create links remained visible with no page overflow. A separate read-only owner-Web browser check confirmed Invemory-Prod and its eight imported definitions; no owner data was changed. + +Push channels also reference the service by ID and block service deletion while linked. +Reassign the push channel first; see [Push Channels](android-push.md). diff --git a/docs/supported-message-types.md b/docs/supported-message-types.md new file mode 100644 index 0000000..243a72a --- /dev/null +++ b/docs/supported-message-types.md @@ -0,0 +1,26 @@ +# Supported incoming message types + +The Worker uses `SupportedMessageType` for its two incoming commands: + +| AMQP MessageType (unchanged) | Public enum | Consumer queue | +| --- | --- | --- | +| `Notification.SendEmail.v1` | `SendEmailV1` | `notification.send-email` | +| `Notification.SendPush.v1` | `SendPushV1` | `notification.send-push` | + +`SupportedMessageTypeParser` maps the transport string at the validation boundary. +Matching is exact and case-sensitive. Unknown versions, missing values, whitespace +variants, enum names and numeric strings return no enum value and are rejected. +Each consumer's delivery validator accepts only its own enum member; a supported +command sent to the other consumer is also rejected before delivery. + +The public enum lives in `HrynCo.NotificationService.Contracts/Messages/SupportedMessageType.cs`, +in namespace `HrynCo.NotificationService.Contracts.Messages`, and is included in the existing +`HrynCo.NotificationService.Contracts` package. The Worker parser remains internal and all +consumers, validators and tests reference this single Contracts enum. It does not change RabbitMQ headers, serialized +payloads, queues, producers or the shared RabbitMQ library. Result messages are not +incoming delivery commands and are outside this enum's scope. Existing payload and +correlation validation remains unchanged. + +The Push Channels change removes the draft Firebase project/access-token fields from +SendPushMessageData. The enum and AMQP header stay unchanged; see +[push channels](android-push.md) for producer compatibility and rollout order.
CodeVersionsActions