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