Compare commits

...

15 Commits

Author SHA1 Message Date
agrynco d912cd8240 fix(admin): filter template keys by substring
Ref: IT-1627
2026-10-31 10:15:24 +02:00
agrynco ed4293d852 fix(admin): filter template keys by substring
Match both channels case-insensitively while keeping API and delivery lookups exact.

Ref: IT-1627
2026-10-31 10:14:48 +02:00
agrynco 343d67802c feat(api): manage localized push template definitions
Ref: IT-1627
2026-10-31 10:04:18 +02:00
agrynco 517b38791b feat(api): manage localized push template definitions
Add revision-protected reads, atomic upsert and deletion with structured actions. Preserve omitted translations and fix insertion of new push languages.

Ref: IT-1627
2026-10-31 10:03:47 +02:00
agrynco 1351279b36 feat(templates): add localized push templates and actions
Ref: IT-1627
2026-10-31 09:39:07 +02:00
agrynco aba4173827 feat(templates): add localized push templates and actions
Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery.

Ref: IT-1627
2026-10-31 09:38:05 +02:00
agrynco d4ee8fc0c8 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
2026-10-24 12:32:22 +03:00
agrynco 1631786ce2 feat(push): add service-owned firebase channels and delivery
Add push contracts, provider delivery and channel diagnostics. Link email channels to services and document migration and validation.

Ref: IT-1074
2026-10-24 12:31:35 +03:00
agrynco b30dba93e8 Merge pull request 'fix(notification): save smtp sender without reentering password' (#27) from fix/notification-admin-save-password into main 2026-10-18 00:36:12 +03:00
agrynco 1c028313ba fix(notification): save smtp sender without reentering password
Preserve the saved SMTP password when connection settings are unchanged and keep it out of admin forms.

Ref: IT-1601
2026-10-18 00:35:17 +03:00
agrynco c5eee69018 Merge pull request 'fix(notification): enable web smtp test with saved password' (#25) from fix/notification-web-smtp-test into main 2026-10-18 00:20:41 +03:00
agrynco 7ce4087d08 Merge pull request 'fix(kubernetes): support localized template schema' (#24) from feature/it-1601-kubernetes-localizations into main 2026-10-18 00:20:35 +03:00
agrynco 2dd5e318d7 fix(notification): enable web smtp test with saved password
Allow Web SMTP egress on TCP 587 and reuse the saved channel password only when connection settings match.

Ref: IT-1601
2026-10-18 00:17:25 +03:00
agrynco d5b7605da5 fix(kubernetes): support localized template schema
Update migration, runtime grants, sandbox seed, and live upgrade guidance for the normalized email template schema.

Ref: IT-1601
2026-10-17 23:26:27 +03:00
agrynco 121ebbfbcd Merge pull request 'fix(admin): show template language status in editor' (#23) from feature/it-1601-editor-language-status into main 2026-10-17 23:15:29 +03:00
183 changed files with 8722 additions and 221 deletions
+1
View File
@@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- Entity Framework Core --> <!-- Entity Framework Core -->
<PackageVersion Include="FirebaseAdmin" Version="3.7.0" />
<PackageVersion Include="HrynCo.DAL.Abstract" Version="1.0.11" /> <PackageVersion Include="HrynCo.DAL.Abstract" Version="1.0.11" />
<PackageVersion Include="HrynCo.DAL.EF" Version="1.0.11" /> <PackageVersion Include="HrynCo.DAL.EF" Version="1.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" /> <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
@@ -2,6 +2,10 @@ namespace HrynCo.NotificationService.Contracts.Messages;
public record NotificationResultData 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 ServiceName { get; init; }
public required string RecipientEmail { get; init; } public required string RecipientEmail { get; init; }
public required string TemplateKey { get; init; } public required string TemplateKey { get; init; }
@@ -0,0 +1,6 @@
namespace HrynCo.NotificationService.Contracts.Messages;
public sealed record PushActionMetadata
{
public string? ObjectId { get; init; }
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.Contracts.Messages;
public sealed record PushTemplateData
{
public required IReadOnlyDictionary<string, string> Bindings { get; init; }
public required IReadOnlyDictionary<string, PushActionMetadata> Actions { get; init; }
}
@@ -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,19 @@
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 string? Title { get; init; }
public string? Body { get; init; }
public string Route { get; init; } = "/home";
public string? TemplateKey { get; init; }
public string? LanguageCode { get; init; }
public PushTemplateData? TemplateData { get; init; }
public int Version { get; init; } = 1;
public required DateTimeOffset ExpiresAt { get; init; }
public string? Token { get; init; }
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 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 int Priority { get; set; }
public EmailChannelType EmailChannelType { get; set; } public EmailChannelType EmailChannelType { get; set; }
public required EmailChannelSettings Settings { get; set; } public required EmailChannelSettings Settings { get; set; }
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public interface IPushDeliveryRepository
{
Task<PushDeliveryOutcome> DeliverOnceAsync(Guid messageId, string serviceName,
Func<CancellationToken, Task<PushDeliveryOutcome>> deliver, CancellationToken cancellationToken);
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public sealed record PushDeliveryOutcome(string? Error, bool InvalidRegistration = false);
@@ -0,0 +1,44 @@
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Parse only service-account fields; never honor caller-supplied OAuth endpoints.
public sealed class FirebaseServiceAccountCredentials
{
public required string ProjectId { get; init; }
public required string ClientEmail { get; init; }
public required string PrivateKey { get; init; }
public static bool IsValidProjectId(string projectId) => Regex.IsMatch(projectId,
"^[a-z][a-z0-9-]{4,28}[a-z0-9]$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
public static FirebaseServiceAccountCredentials Parse(string json, string projectId)
{
if (!IsValidProjectId(projectId) || json.Length > 32768)
throw new FormatException("Invalid Firebase configuration.");
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (Read(root, "type") != "service_account" || Read(root, "project_id") != projectId)
throw new FormatException("The service account must belong to the selected Firebase project.");
var email = Read(root, "client_email");
if (!MailAddress.TryCreate(email, out var address) || address.Address != email)
throw new FormatException("Invalid service account email.");
var key = Read(root, "private_key");
using var rsa = RSA.Create();
rsa.ImportFromPem(key);
// Exporting private parameters also rejects a public-key-only PEM.
rsa.ExportParameters(true);
return new FirebaseServiceAccountCredentials { ProjectId = projectId, ClientEmail = email, PrivateKey = key };
}
private static string Read(JsonElement root, string name)
{
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var value)
|| value.ValueKind != JsonValueKind.String)
throw new FormatException("Missing service account fields.");
return value.GetString() ?? string.Empty;
}
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public interface IPushChannelRepository
{
Task<IReadOnlyList<PushChannel>> GetAllAsync(CancellationToken ct = default);
Task<PushChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<PushChannel?> GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default);
Task<string?> GetCredentialsAsync(Guid id, CancellationToken ct = default);
Task SaveAsync(PushChannel channel, string credentialsJson, bool isNew, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Safe administration/read model: credentials are fetched separately by server code.
public sealed class PushChannel : Entity
{
public required Guid ServiceId { get; set; }
public string ServiceName { get; set; } = string.Empty;
public required string FirebaseProjectId { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,6 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class PushChannelWriteException(string message, bool isConflict = true) : Exception(message)
{
public bool IsConflict { get; } = isConflict;
}
@@ -5,7 +5,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IEmailChannelRepository public interface IEmailChannelRepository
{ {
Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default); 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<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default); Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default);
Task AddAsync(EmailChannel channel, CancellationToken ct = default); Task AddAsync(EmailChannel channel, CancellationToken ct = default);
@@ -0,0 +1,12 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IPushTemplateDefinitionRepository
{
Task<PushTemplateDefinition?> UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default);
Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default);
Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default);
Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default);
Task<bool> DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default);
}
@@ -0,0 +1,49 @@
using System.Text.Json;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public static class PushTemplateActionValidation
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = false
};
public static PushTemplateActions Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new EmailTemplateWriteException("Actions JSON is required. Use {} for no actions.", false);
try
{
var actions = JsonSerializer.Deserialize<PushTemplateActions>(json, JsonOptions)
?? throw new JsonException();
Validate(actions);
return actions;
}
catch (JsonException)
{
throw new EmailTemplateWriteException("Invalid actions JSON. Use defaultAction and buttons with action and label.", false);
}
}
public static void Validate(PushTemplateActions actions)
{
if (actions.DefaultAction is not null) ValidateKey(actions.DefaultAction);
if (actions.Buttons is null || actions.Buttons.Count > 3)
throw new EmailTemplateWriteException("Configure up to three push buttons.", false);
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var button in actions.Buttons)
{
if (button is null) throw new EmailTemplateWriteException("A button needs an action and label.", false);
ValidateKey(button.Action);
if (!keys.Add(button.Action) || string.IsNullOrWhiteSpace(button.Label) || button.Label.Length > 100)
throw new EmailTemplateWriteException("Button actions must be unique and labels must contain 1–100 characters.", false);
}
}
private static void ValidateKey(string key)
{
if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || key.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '_' and not '-' and not '.'))
throw new EmailTemplateWriteException("Action keys need 1–100 letters, digits, dots, underscores or hyphens.", false);
}
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record PushTemplateActions
{
public string? DefaultAction { get; init; }
public IReadOnlyList<PushTemplateButton> Buttons { get; init; } = [];
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record PushTemplateButton
{
public required string Action { get; init; }
public required string Label { get; init; }
}
@@ -0,0 +1,11 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class PushTemplateDefinition
{
public Guid Id { get; set; }
public Guid ServiceId { get; set; }
public required string Key { get; set; }
public Guid Revision { get; set; }
public IReadOnlyList<EmailTemplateVariable> Variables { get; set; } = [];
public IReadOnlyList<PushTemplateTranslation> Languages { get; set; } = [];
}
@@ -0,0 +1,11 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class PushTemplateDefinitionPatch
{
public Guid ServiceId { get; init; }
public required string Key { get; init; }
public Guid? Revision { get; init; }
public required IReadOnlyList<EmailTemplateVariable> Variables { get; init; }
public required IReadOnlyList<PushTemplateTranslation> Translations { get; init; }
public IReadOnlyList<string> RemoveLanguageCodes { get; init; } = [];
}
@@ -0,0 +1,9 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class PushTemplateTranslation
{
public string ActionsJson { get; set; } = "{}";
public required string LanguageCode { get; set; }
public required string Title { get; set; }
public required string Body { get; set; }
}
@@ -0,0 +1,42 @@
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public static partial class PushTemplateValidation
{
public static void Validate(PushTemplateDefinition definition)
{
EmailTemplateValidation.ValidateIdentity("Push", definition.Key);
EmailTemplateValidation.ValidateVariables(definition.Variables);
var names = definition.Variables.Select(v => v.Name).ToHashSet(StringComparer.Ordinal);
foreach (var language in definition.Languages)
ValidateTranslation(language, names);
if (definition.Languages.Select(l => l.LanguageCode).Distinct(StringComparer.Ordinal).Count() != definition.Languages.Count)
throw new EmailTemplateWriteException("Language codes must be unique within a template.", false);
}
private static void ValidateTranslation(PushTemplateTranslation translation, HashSet<string> names)
{
if (string.IsNullOrWhiteSpace(translation.LanguageCode) || translation.LanguageCode.Length > 10)
throw new EmailTemplateWriteException("A valid language code is required.", false);
if (string.IsNullOrWhiteSpace(translation.Title) || string.IsNullOrWhiteSpace(translation.Body))
throw new EmailTemplateWriteException("Every populated push language needs both Title and Body.", false);
ValidatePlaceholders(translation.Title, names);
ValidatePlaceholders(translation.Body, names);
var actions = PushTemplateActionValidation.Parse(translation.ActionsJson);
foreach (var button in actions.Buttons) ValidatePlaceholders(button.Label, names);
}
private static void ValidatePlaceholders(string text, HashSet<string> names)
{
foreach (Match match in Placeholder().Matches(text))
if (!names.Contains(match.Groups[1].Value))
throw new EmailTemplateWriteException("Every placeholder must be declared in this channel's variables.", false);
var remainder = Placeholder().Replace(text, "");
if (remainder.Contains("{{", StringComparison.Ordinal) || remainder.Contains("}}", StringComparison.Ordinal))
throw new EmailTemplateWriteException("Use complete placeholders in the form {{VariableName}}.", false);
}
[GeneratedRegex(@"\{\{([^{}]+)\}\}")]
private static partial Regex Placeholder();
}
@@ -13,12 +13,10 @@ internal class EmailChannelEntityConfiguration : IEntityTypeConfiguration<EmailC
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id"); builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceName) builder.Property(x => x.ServiceId).HasColumnName("service_id").IsRequired();
.HasColumnName("service_name") builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId)
.IsRequired() .OnDelete(DeleteBehavior.Restrict);
.HasMaxLength(100); builder.HasIndex(x => new { x.ServiceId, x.Priority });
builder.HasIndex(x => new { x.ServiceName, x.Priority });
builder.Property(x => x.Priority).HasColumnName("priority"); builder.Property(x => x.Priority).HasColumnName("priority");
@@ -17,6 +17,9 @@ internal sealed class EmailTemplateDefinitionEntityConfiguration : IEntityTypeCo
builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique(); builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique();
builder.HasAlternateKey(x => new { x.Id, x.ServiceId }); builder.HasAlternateKey(x => new { x.Id, x.ServiceId });
builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict); builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict);
builder.HasOne(x => x.Template).WithOne(x => x.Email)
.HasForeignKey<EmailTemplateDefinitionEntity>(x => new { x.ServiceId, x.Key })
.HasPrincipalKey<TemplateEntity>(x => new { x.ServiceId, x.Key }).OnDelete(DeleteBehavior.Cascade);
builder.Property(x => x.Created).HasColumnName("created"); builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated"); builder.Property(x => x.Updated).HasColumnName("updated");
builder.OwnsMany(x => x.Variables, v => builder.OwnsMany(x => x.Variables, v =>
@@ -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);
}
}
@@ -0,0 +1,31 @@
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal sealed class PushTemplateDefinitionEntityConfiguration : IEntityTypeConfiguration<PushTemplateDefinitionEntity>
{
public void Configure(EntityTypeBuilder<PushTemplateDefinitionEntity> builder)
{
builder.ToTable("push_template_definitions");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceId).HasColumnName("service_id");
builder.Property(x => x.Key).HasColumnName("key").HasMaxLength(100).IsRequired();
builder.Property(x => x.Revision).HasColumnName("revision").IsConcurrencyToken();
builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique();
builder.HasAlternateKey(x => new { x.Id, x.ServiceId });
builder.HasOne(x => x.Template).WithOne(x => x.Push)
.HasForeignKey<PushTemplateDefinitionEntity>(x => new { x.ServiceId, x.Key })
.HasPrincipalKey<TemplateEntity>(x => new { x.ServiceId, x.Key }).OnDelete(DeleteBehavior.Cascade);
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
builder.OwnsMany(x => x.Variables, v =>
{
v.ToJson("variables");
v.Property(x => x.Name).HasJsonPropertyName("name");
v.Property(x => x.Required).HasJsonPropertyName("required");
});
}
}
@@ -0,0 +1,30 @@
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal sealed class PushTemplateEntityConfiguration : IEntityTypeConfiguration<PushTemplateEntity>
{
public void Configure(EntityTypeBuilder<PushTemplateEntity> builder)
{
builder.ToTable("push_templates");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.DefinitionId).HasColumnName("definition_id");
builder.Property(x => x.ServiceId).HasColumnName("service_id");
builder.Property(x => x.ServiceLanguageId).HasColumnName("service_language_id");
builder.HasIndex(x => new { x.DefinitionId, x.ServiceLanguageId }).IsUnique();
builder.HasOne(x => x.Definition).WithMany(x => x.Languages)
.HasForeignKey(x => new { x.DefinitionId, x.ServiceId })
.HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Cascade);
builder.HasOne(x => x.ServiceLanguage).WithMany()
.HasForeignKey(x => new { x.ServiceLanguageId, x.ServiceId })
.HasPrincipalKey(x => new { x.Id, x.ServiceId }).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Title).HasColumnName("title").IsRequired();
builder.Property(x => x.Body).HasColumnName("body").IsRequired();
builder.Property(x => x.ActionsJson).HasColumnName("actions").HasColumnType("jsonb").IsRequired();
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
}
}
@@ -0,0 +1,21 @@
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace HrynCo.NotificationService.DAL.EF.Configurations;
internal sealed class TemplateEntityConfiguration : IEntityTypeConfiguration<TemplateEntity>
{
public void Configure(EntityTypeBuilder<TemplateEntity> builder)
{
builder.ToTable("templates");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceId).HasColumnName("service_id");
builder.Property(x => x.Key).HasColumnName("key").HasMaxLength(100).IsRequired();
builder.HasAlternateKey(x => new { x.ServiceId, x.Key });
builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId).OnDelete(DeleteBehavior.Restrict);
builder.Property(x => x.Created).HasColumnName("created");
builder.Property(x => x.Updated).HasColumnName("updated");
}
}
@@ -5,7 +5,8 @@ namespace HrynCo.NotificationService.DAL.EF.Entities;
internal class EmailChannelEntity : Entity 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 int Priority { get; set; }
public EmailChannelType EmailChannelType { get; set; } public EmailChannelType EmailChannelType { get; set; }
@@ -4,6 +4,7 @@ namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class EmailTemplateDefinitionEntity : Entity internal sealed class EmailTemplateDefinitionEntity : Entity
{ {
public TemplateEntity Template { get; set; } = null!;
public Guid ServiceId { get; set; } public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!; public ServiceEntity Service { get; set; } = null!;
public required string Key { get; set; } public required string Key { get; set; }
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushChannelEntity : Entity
{
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
public required string FirebaseProjectId { get; set; }
public required string CredentialsJson { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.DAL.EF.Entities;
public sealed class PushDeliveryEntity
{
public Guid MessageId { get; set; }
public string ServiceName { get; set; } = "";
public string? Error { get; set; }
public bool InvalidRegistration { get; set; }
public DateTimeOffset CompletedAt { get; set; }
}
@@ -0,0 +1,13 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushTemplateDefinitionEntity : Entity
{
public Guid ServiceId { get; set; }
public required string Key { get; set; }
public TemplateEntity Template { get; set; } = null!;
public Guid Revision { get; set; } = Guid.NewGuid();
public List<PushTemplateVariableData> Variables { get; set; } = [];
public List<PushTemplateEntity> Languages { get; set; } = [];
}
@@ -0,0 +1,15 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushTemplateEntity : Entity
{
public Guid DefinitionId { get; set; }
public PushTemplateDefinitionEntity Definition { get; set; } = null!;
public Guid ServiceId { get; set; }
public Guid ServiceLanguageId { get; set; }
public ServiceLanguageEntity ServiceLanguage { get; set; } = null!;
public required string Title { get; set; }
public required string Body { get; set; }
public string ActionsJson { get; set; } = "{}";
}
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class PushTemplateVariableData
{
public required string Name { get; set; }
public bool Required { get; set; }
}
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class TemplateEntity : Entity
{
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
public required string Key { get; set; }
public EmailTemplateDefinitionEntity? Email { get; set; }
public PushTemplateDefinitionEntity? Push { get; set; }
}
@@ -0,0 +1,402 @@
// <auto-generated />
using System;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
[DbContext(typeof(NotificationDbContext))]
[Migration("20261023223753_AddPushDeliveries")]
partial class AddPushDeliveries
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasColumnName("daily_limit");
b.Property<int>("EmailChannelType")
.HasColumnType("integer")
.HasColumnName("provider_type");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<int?>("MonthlyLimit")
.HasColumnType("integer")
.HasColumnName("monthly_limit");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.Property<int>("WarnThresholdPercent")
.HasColumnType("integer")
.HasColumnName("warn_threshold_percent");
b.HasKey("Id");
b.HasIndex("ServiceName", "Priority");
b.ToTable("email_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid")
.HasColumnName("provider_id");
b.Property<int>("SentCount")
.HasColumnType("integer")
.HasColumnName("sent_count");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ProviderId", "Date")
.IsUnique();
b.ToTable("email_channel_usage", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("email_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("html_body");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("TextBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text_body");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("email_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("services", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Code")
.IsUnique();
b.ToTable("service_languages", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddPushDeliveries : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PushDeliveries",
columns: table => new
{
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
ServiceName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
Error = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
InvalidRegistration = table.Column<bool>(type: "boolean", nullable: false),
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PushDeliveries", x => new { x.ServiceName, x.MessageId });
});
migrationBuilder.CreateIndex(
name: "IX_PushDeliveries_CompletedAt",
table: "PushDeliveries",
column: "CompletedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PushDeliveries");
}
}
}
@@ -0,0 +1,411 @@
// <auto-generated />
using System;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
[DbContext(typeof(NotificationDbContext))]
[Migration("20261024073202_LinkEmailChannelsToServices")]
partial class LinkEmailChannelsToServices
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasColumnName("daily_limit");
b.Property<int>("EmailChannelType")
.HasColumnType("integer")
.HasColumnName("provider_type");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<int?>("MonthlyLimit")
.HasColumnType("integer")
.HasColumnName("monthly_limit");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.Property<int>("WarnThresholdPercent")
.HasColumnType("integer")
.HasColumnName("warn_threshold_percent");
b.HasKey("Id");
b.HasIndex("ServiceId", "Priority");
b.ToTable("email_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid")
.HasColumnName("provider_id");
b.Property<int>("SentCount")
.HasColumnType("integer")
.HasColumnName("sent_count");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ProviderId", "Date")
.IsUnique();
b.ToTable("email_channel_usage", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("email_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("html_body");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("TextBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text_body");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("email_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("services", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Code")
.IsUnique();
b.ToTable("service_languages", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations;
public partial class LinkEmailChannelsToServices : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// EF runs this whole migration in one transaction. Lock the source rows so a
// concurrent rename or channel write cannot invalidate the verified mapping.
migrationBuilder.Sql("""
LOCK TABLE services IN SHARE ROW EXCLUSIVE MODE;
LOCK TABLE email_channels IN ACCESS EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM email_channels c
WHERE (SELECT count(*) FROM services s
WHERE s.name = c.service_name
OR (c.service_name = 'StoreMate-Prod' AND s.name = 'Invemory-Prod')) <> 1
) THEN
RAISE EXCEPTION 'Email channel migration stopped: every legacy service name must have exactly one existing service (including the approved StoreMate-Prod -> Invemory-Prod alias). Resolve missing or ambiguous names before retrying.';
END IF;
END $$;
ALTER TABLE email_channels ADD COLUMN service_id uuid;
UPDATE email_channels c SET service_id = s.id
FROM services s
WHERE s.name = c.service_name
OR (c.service_name = 'StoreMate-Prod' AND s.name = 'Invemory-Prod');
ALTER TABLE email_channels ALTER COLUMN service_id SET NOT NULL;
""");
migrationBuilder.DropIndex("IX_email_channels_service_name_priority", "email_channels");
migrationBuilder.DropColumn("service_name", "email_channels");
migrationBuilder.CreateIndex("IX_email_channels_service_id_priority", "email_channels", new[] { "service_id", "priority" });
migrationBuilder.AddForeignKey("FK_email_channels_services_service_id", "email_channels", "service_id",
"services", principalColumn: "id", onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
// Restore current service names; historical names are intentionally not retained.
migrationBuilder.Sql("""
ALTER TABLE email_channels ADD COLUMN service_name character varying(100);
UPDATE email_channels c SET service_name = s.name FROM services s WHERE s.id = c.service_id;
ALTER TABLE email_channels ALTER COLUMN service_name SET NOT NULL;
""");
migrationBuilder.DropForeignKey("FK_email_channels_services_service_id", "email_channels");
migrationBuilder.DropIndex("IX_email_channels_service_id_priority", "email_channels");
migrationBuilder.DropColumn("service_id", "email_channels");
migrationBuilder.CreateIndex("IX_email_channels_service_name_priority", "email_channels", new[] { "service_name", "priority" });
}
}
@@ -0,0 +1,464 @@
// <auto-generated />
using System;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
[DbContext(typeof(NotificationDbContext))]
[Migration("20261024080457_AddPushChannels")]
partial class AddPushChannels
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasColumnName("daily_limit");
b.Property<int>("EmailChannelType")
.HasColumnType("integer")
.HasColumnName("provider_type");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<int?>("MonthlyLimit")
.HasColumnType("integer")
.HasColumnName("monthly_limit");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.Property<int>("WarnThresholdPercent")
.HasColumnType("integer")
.HasColumnName("warn_threshold_percent");
b.HasKey("Id");
b.HasIndex("ServiceId", "Priority");
b.ToTable("email_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid")
.HasColumnName("provider_id");
b.Property<int>("SentCount")
.HasColumnType("integer")
.HasColumnName("sent_count");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ProviderId", "Date")
.IsUnique();
b.ToTable("email_channel_usage", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("email_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("html_body");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("TextBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text_body");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("email_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("CredentialsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("credentials");
b.Property<string>("FirebaseProjectId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("firebase_project_id");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId")
.IsUnique();
b.ToTable("push_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("services", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Code")
.IsUnique();
b.ToTable("service_languages", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,51 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddPushChannels : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "push_channels",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
firebase_project_id = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
credentials = table.Column<string>(type: "jsonb", nullable: false),
is_active = table.Column<bool>(type: "boolean", nullable: false),
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_push_channels", x => x.id);
table.ForeignKey(
name: "FK_push_channels_services_service_id",
column: x => x.service_id,
principalTable: "services",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_push_channels_service_id",
table: "push_channels",
column: "service_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "push_channels");
}
}
}
@@ -0,0 +1,674 @@
// <auto-generated />
using System;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
[DbContext(typeof(NotificationDbContext))]
[Migration("20261030215421_AddChannelTemplates")]
partial class AddChannelTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasColumnName("daily_limit");
b.Property<int>("EmailChannelType")
.HasColumnType("integer")
.HasColumnName("provider_type");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<int?>("MonthlyLimit")
.HasColumnType("integer")
.HasColumnName("monthly_limit");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.Property<int>("WarnThresholdPercent")
.HasColumnType("integer")
.HasColumnName("warn_threshold_percent");
b.HasKey("Id");
b.HasIndex("ServiceId", "Priority");
b.ToTable("email_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid")
.HasColumnName("provider_id");
b.Property<int>("SentCount")
.HasColumnType("integer")
.HasColumnName("sent_count");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ProviderId", "Date")
.IsUnique();
b.ToTable("email_channel_usage", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("email_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("html_body");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("TextBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text_body");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("email_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("CredentialsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("credentials");
b.Property<string>("FirebaseProjectId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("firebase_project_id");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId")
.IsUnique();
b.ToTable("push_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("push_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("push_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("services", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Code")
.IsUnique();
b.ToTable("service_languages", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.ToTable("templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Email")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Push")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("PushTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("push_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("PushTemplateDefinitionEntityId");
});
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Navigation("Email");
b.Navigation("Push");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,151 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddChannelTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "templates",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_templates", x => x.id);
table.UniqueConstraint("AK_templates_service_id_key", x => new { x.service_id, x.key });
table.ForeignKey(
name: "FK_templates_services_service_id",
column: x => x.service_id,
principalTable: "services",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "push_template_definitions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
revision = table.Column<Guid>(type: "uuid", nullable: false),
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
variables = table.Column<string>(type: "jsonb", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_push_template_definitions", x => x.id);
table.UniqueConstraint("AK_push_template_definitions_id_service_id", x => new { x.id, x.service_id });
table.ForeignKey(
name: "FK_push_template_definitions_templates_service_id_key",
columns: x => new { x.service_id, x.key },
principalTable: "templates",
principalColumns: new[] { "service_id", "key" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "push_templates",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
definition_id = table.Column<Guid>(type: "uuid", nullable: false),
service_id = table.Column<Guid>(type: "uuid", nullable: false),
service_language_id = table.Column<Guid>(type: "uuid", nullable: false),
title = table.Column<string>(type: "text", nullable: false),
body = table.Column<string>(type: "text", nullable: false),
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_push_templates", x => x.id);
table.ForeignKey(
name: "FK_push_templates_push_template_definitions_definition_id_serv~",
columns: x => new { x.definition_id, x.service_id },
principalTable: "push_template_definitions",
principalColumns: new[] { "id", "service_id" },
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_push_templates_service_languages_service_language_id_servic~",
columns: x => new { x.service_language_id, x.service_id },
principalTable: "service_languages",
principalColumns: new[] { "id", "service_id" },
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_push_template_definitions_service_id_key",
table: "push_template_definitions",
columns: new[] { "service_id", "key" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_push_templates_definition_id_service_id",
table: "push_templates",
columns: new[] { "definition_id", "service_id" });
migrationBuilder.CreateIndex(
name: "IX_push_templates_definition_id_service_language_id",
table: "push_templates",
columns: new[] { "definition_id", "service_language_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_push_templates_service_language_id_service_id",
table: "push_templates",
columns: new[] { "service_language_id", "service_id" });
// Preserve existing email identity, variables, revisions and translations in place.
migrationBuilder.Sql("""
INSERT INTO templates (id, service_id, key, created, updated)
SELECT id, service_id, key, created, updated FROM email_template_definitions;
""");
migrationBuilder.AddForeignKey(
name: "FK_email_template_definitions_templates_service_id_key",
table: "email_template_definitions",
columns: new[] { "service_id", "key" },
principalTable: "templates",
principalColumns: new[] { "service_id", "key" },
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM push_template_definitions) THEN
RAISE EXCEPTION 'Cannot roll back channel templates while push definitions exist. Export and explicitly remove them first.';
END IF;
END $$;
""");
migrationBuilder.DropForeignKey(
name: "FK_email_template_definitions_templates_service_id_key",
table: "email_template_definitions");
migrationBuilder.DropTable(
name: "push_templates");
migrationBuilder.DropTable(
name: "push_template_definitions");
migrationBuilder.DropTable(
name: "templates");
}
}
}
@@ -0,0 +1,679 @@
// <auto-generated />
using System;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
[DbContext(typeof(NotificationDbContext))]
[Migration("20261030235620_AddPushTemplateActions")]
partial class AddPushTemplateActions
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<int?>("DailyLimit")
.HasColumnType("integer")
.HasColumnName("daily_limit");
b.Property<int>("EmailChannelType")
.HasColumnType("integer")
.HasColumnName("provider_type");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<int?>("MonthlyLimit")
.HasColumnType("integer")
.HasColumnName("monthly_limit");
b.Property<int>("Priority")
.HasColumnType("integer")
.HasColumnName("priority");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<string>("SettingsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.Property<int>("WarnThresholdPercent")
.HasColumnType("integer")
.HasColumnName("warn_threshold_percent");
b.HasKey("Id");
b.HasIndex("ServiceId", "Priority");
b.ToTable("email_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date");
b.Property<Guid>("ProviderId")
.HasColumnType("uuid")
.HasColumnName("provider_id");
b.Property<int>("SentCount")
.HasColumnType("integer")
.HasColumnName("sent_count");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ProviderId", "Date")
.IsUnique();
b.ToTable("email_channel_usage", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("email_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<string>("HtmlBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("html_body");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("text")
.HasColumnName("subject");
b.Property<string>("TextBody")
.IsRequired()
.HasColumnType("text")
.HasColumnName("text_body");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("email_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("CredentialsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("credentials");
b.Property<string>("FirebaseProjectId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("firebase_project_id");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId")
.IsUnique();
b.ToTable("push_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("push_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ActionsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("actions");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("push_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("services", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)")
.HasColumnName("code");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Code")
.IsUnique();
b.ToTable("service_languages", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.ToTable("templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
.WithMany("UsageRecords")
.HasForeignKey("ProviderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Email")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("email_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("EmailTemplateDefinitionEntityId");
});
b.Navigation("Service");
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Push")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("PushTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("push_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("PushTemplateDefinitionEntityId");
});
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany("Languages")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
b.Navigation("UsageRecords");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Navigation("Email");
b.Navigation("Push");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace HrynCo.NotificationService.DAL.EF.Migrations
{
/// <inheritdoc />
public partial class AddPushTemplateActions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "actions",
table: "push_templates",
type: "jsonb",
nullable: false,
defaultValue: "{}");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "actions",
table: "push_templates");
}
}
}
@@ -53,11 +53,9 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("priority"); .HasColumnName("priority");
b.Property<string>("ServiceName") b.Property<Guid>("ServiceId")
.IsRequired() .HasColumnType("uuid")
.HasMaxLength(100) .HasColumnName("service_id");
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<string>("SettingsJson") b.Property<string>("SettingsJson")
.IsRequired() .IsRequired()
@@ -74,7 +72,7 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ServiceName", "Priority"); b.HasIndex("ServiceId", "Priority");
b.ToTable("email_channels", (string)null); b.ToTable("email_channels", (string)null);
}); });
@@ -206,6 +204,166 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.ToTable("email_templates", (string)null); b.ToTable("email_templates", (string)null);
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("CredentialsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("credentials");
b.Property<string>("FirebaseProjectId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("firebase_project_id");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId")
.IsUnique();
b.ToTable("push_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("Revision")
.IsConcurrencyToken()
.HasColumnType("uuid")
.HasColumnName("revision");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId", "Key")
.IsUnique();
b.ToTable("push_template_definitions", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<string>("ActionsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("actions");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text")
.HasColumnName("body");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<Guid>("DefinitionId")
.HasColumnType("uuid")
.HasColumnName("definition_id");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<Guid>("ServiceLanguageId")
.HasColumnType("uuid")
.HasColumnName("service_language_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text")
.HasColumnName("title");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("DefinitionId", "ServiceId");
b.HasIndex("DefinitionId", "ServiceLanguageId")
.IsUnique();
b.HasIndex("ServiceLanguageId", "ServiceId");
b.ToTable("push_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -273,6 +431,47 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.ToTable("service_languages", (string)null); b.ToTable("service_languages", (string)null);
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("key");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.ToTable("templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{
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 => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
{ {
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null) b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
@@ -290,6 +489,13 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
.OnDelete(DeleteBehavior.Restrict) .OnDelete(DeleteBehavior.Restrict)
.IsRequired(); .IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Email")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 => b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
{ {
b1.Property<Guid>("EmailTemplateDefinitionEntityId"); b1.Property<Guid>("EmailTemplateDefinitionEntityId");
@@ -318,6 +524,8 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Service"); b.Navigation("Service");
b.Navigation("Template");
b.Navigation("Variables"); b.Navigation("Variables");
}); });
@@ -342,6 +550,78 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("ServiceLanguage"); b.Navigation("ServiceLanguage");
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "Template")
.WithOne("Push")
.HasForeignKey("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "ServiceId", "Key")
.HasPrincipalKey("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", "ServiceId", "Key")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateVariableData", "Variables", b1 =>
{
b1.Property<Guid>("PushTemplateDefinitionEntityId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Name")
.IsRequired()
.HasJsonPropertyName("name");
b1.Property<bool>("Required")
.HasJsonPropertyName("required");
b1.HasKey("PushTemplateDefinitionEntityId", "__synthesizedOrdinal");
b1.ToTable("push_template_definitions");
b1
.ToJson("variables")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("PushTemplateDefinitionEntityId");
});
b.Navigation("Template");
b.Navigation("Variables");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", "Definition")
.WithMany("Languages")
.HasForeignKey("DefinitionId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
.WithMany()
.HasForeignKey("ServiceLanguageId", "ServiceId")
.HasPrincipalKey("Id", "ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Definition");
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
{ {
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service") b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
@@ -353,6 +633,17 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Service"); b.Navigation("Service");
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
{ {
b.Navigation("UsageRecords"); b.Navigation("UsageRecords");
@@ -363,10 +654,22 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Languages"); b.Navigation("Languages");
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushTemplateDefinitionEntity", b =>
{
b.Navigation("Languages");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b => modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
{ {
b.Navigation("Languages"); b.Navigation("Languages");
}); });
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.TemplateEntity", b =>
{
b.Navigation("Email");
b.Navigation("Push");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
@@ -10,7 +10,11 @@ public class NotificationDbContext : DbContext
{ {
} }
internal DbSet<TemplateEntity> TemplateCatalog => Set<TemplateEntity>();
internal DbSet<PushTemplateDefinitionEntity> PushTemplateDefinitions => Set<PushTemplateDefinitionEntity>();
internal DbSet<PushTemplateEntity> PushTemplates => Set<PushTemplateEntity>();
internal DbSet<ServiceLanguageEntity> ServiceLanguages => Set<ServiceLanguageEntity>(); internal DbSet<ServiceLanguageEntity> ServiceLanguages => Set<ServiceLanguageEntity>();
internal DbSet<PushChannelEntity> PushChannels => Set<PushChannelEntity>();
internal DbSet<ServiceEntity> Services => Set<ServiceEntity>(); internal DbSet<ServiceEntity> Services => Set<ServiceEntity>();
internal DbSet<EmailTemplateDefinitionEntity> TemplateDefinitions => Set<EmailTemplateDefinitionEntity>(); internal DbSet<EmailTemplateDefinitionEntity> TemplateDefinitions => Set<EmailTemplateDefinitionEntity>();
internal DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>(); internal DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>();
@@ -8,9 +8,10 @@ internal sealed class NotificationDbContextFactory : IDesignTimeDbContextFactory
public NotificationDbContext CreateDbContext(string[] args) public NotificationDbContext CreateDbContext(string[] args)
{ {
var options = new DbContextOptionsBuilder<NotificationDbContext>() 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; .Options;
return new NotificationDbContext(options); return new NotificationDbContext(options);
} }
} }
@@ -15,14 +15,14 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default) 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(); 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) .OrderBy(x => x.Priority)
.ToListAsync(ct); .ToListAsync(ct);
@@ -32,8 +32,8 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync( public async Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(
DateOnly today, CancellationToken ct = default) DateOnly today, CancellationToken ct = default)
{ {
var rows = await EfRepository.Get() var rows = await EfRepository.Get().AsNoTracking().Include(c => c.Service)
.OrderBy(c => c.ServiceName) .OrderBy(c => c.Service.Name)
.ThenBy(c => c.Priority) .ThenBy(c => c.Priority)
.Select(c => new .Select(c => new
{ {
@@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default) 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); return entity is null ? null : MapToDomain(entity);
} }
@@ -79,7 +79,8 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
return new EmailChannel return new EmailChannel
{ {
Id = e.Id, Id = e.Id,
ServiceName = e.ServiceName, ServiceId = e.ServiceId,
ServiceName = e.Service.Name,
Priority = e.Priority, Priority = e.Priority,
EmailChannelType = e.EmailChannelType, EmailChannelType = e.EmailChannelType,
Settings = DeserializeSettings(e.EmailChannelType, e.SettingsJson), Settings = DeserializeSettings(e.EmailChannelType, e.SettingsJson),
@@ -97,7 +98,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
return new EmailChannelEntity return new EmailChannelEntity
{ {
Id = p.Id, Id = p.Id,
ServiceName = p.ServiceName, ServiceId = p.ServiceId,
Priority = p.Priority, Priority = p.Priority,
EmailChannelType = p.EmailChannelType, EmailChannelType = p.EmailChannelType,
SettingsJson = JsonSerializer.Serialize(p.Settings, p.Settings.GetType()), SettingsJson = JsonSerializer.Serialize(p.Settings, p.Settings.GetType()),
@@ -78,6 +78,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
if (entity is not null) if (entity is not null)
throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating."); throw new EmailTemplateWriteException("A definition with this service and key already exists. Read its revision before updating.");
entity = new EmailTemplateDefinitionEntity { Service = service, ServiceId = service.Id, Key = patch.Key, Created = DateTimeOffset.UtcNow }; entity = new EmailTemplateDefinitionEntity { Service = service, ServiceId = service.Id, Key = patch.Key, Created = DateTimeOffset.UtcNow };
entity.Template = await GetOrCreateTemplateAsync(service, patch.Key, ct);
db.TemplateDefinitions.Add(entity); db.TemplateDefinitions.Add(entity);
return entity; return entity;
} }
@@ -125,6 +126,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
if (definition.Revision != revision) if (definition.Revision != revision)
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting."); throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
db.TemplateDefinitions.Remove(definition); db.TemplateDefinitions.Remove(definition);
await RemoveEmptyTemplateAsync(serviceId, key, ct);
removed = true; removed = true;
}, ct); }, ct);
return removed; return removed;
@@ -261,12 +263,31 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false); ?? throw new EmailTemplateWriteException("Service does not exist. Create the service and its language codes first.", false);
var entity = new EmailTemplateDefinitionEntity var entity = new EmailTemplateDefinitionEntity
{ {
Template = await GetOrCreateTemplateAsync(service, key, ct),
Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow
}; };
db.TemplateDefinitions.Add(entity); db.TemplateDefinitions.Add(entity);
return entity; return entity;
} }
private async Task<TemplateEntity> GetOrCreateTemplateAsync(ServiceEntity service, string key, CancellationToken ct)
{
var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == key, ct);
if (template is not null)
return template;
template = new TemplateEntity { Service = service, ServiceId = service.Id, Key = key, Created = DateTimeOffset.UtcNow };
db.TemplateCatalog.Add(template);
return template;
}
private async Task RemoveEmptyTemplateAsync(Guid serviceId, string key, CancellationToken ct)
{
if (await db.PushTemplateDefinitions.AnyAsync(t => t.ServiceId == serviceId && t.Key == key, ct))
return;
var template = await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct);
db.TemplateCatalog.Remove(template);
}
private static void ValidateLegacy(EmailTemplate template) private static void ValidateLegacy(EmailTemplate template)
{ {
EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key); EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key);
@@ -38,8 +38,7 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return; return;
if (service.Revision != revision) if (service.Revision != revision)
throw new EmailTemplateWriteException("The service revision changed. Read it again before deleting."); throw new EmailTemplateWriteException("The service revision changed. Read it again before deleting.");
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct)) await EnsureServiceIsEmptyAsync(id, ct);
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
db.ServiceLanguages.RemoveRange(service.Languages); db.ServiceLanguages.RemoveRange(service.Languages);
db.Services.Remove(service); db.Services.Remove(service);
removed = true; removed = true;
@@ -47,6 +46,14 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return removed; return removed;
} }
private async Task EnsureServiceIsEmptyAsync(Guid id, CancellationToken ct)
{
if (await db.TemplateCatalog.AnyAsync(d => d.ServiceId == id, ct))
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
if (await db.Providers.AnyAsync(c => c.ServiceId == id, ct) || await db.PushChannels.AnyAsync(c => c.ServiceId == id, ct))
throw new EmailTemplateWriteException("Reassign or remove every notification channel before deleting this service.");
}
public Task SaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () => public Task SaveAsync(NotificationServiceInfo model, bool isNew, CancellationToken ct = default) => mutations.ExecuteAsync(async () =>
{ {
Validate(model); Validate(model);
@@ -69,9 +76,11 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
var language = service.Languages.SingleOrDefault(l => l.Id == languageId) var language = service.Languages.SingleOrDefault(l => l.Id == languageId)
?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service."); ?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service.");
var versions = await db.Templates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct); var versions = await db.Templates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
if (service.Revision != expectedRevision || versions.Count != expectedVersionCount) var pushVersions = await db.PushTemplates.Where(t => t.ServiceLanguageId == languageId).ToListAsync(ct);
if (service.Revision != expectedRevision || versions.Count + pushVersions.Count != expectedVersionCount)
throw new EmailTemplateWriteException("The service or affected version count changed. Review the current count and confirm again."); throw new EmailTemplateWriteException("The service or affected version count changed. Review the current count and confirm again.");
db.Templates.RemoveRange(versions); db.Templates.RemoveRange(versions);
db.PushTemplates.RemoveRange(pushVersions);
// Save dependent removals first because language deletion is restricted by its FK. // Save dependent removals first because language deletion is restricted by its FK.
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
db.ServiceLanguages.Remove(language); db.ServiceLanguages.Remove(language);
@@ -105,7 +114,8 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
var removed = service.Languages.Where(l => !retained.Contains(l.Id)).ToList(); var removed = service.Languages.Where(l => !retained.Contains(l.Id)).ToList();
foreach (var language in removed) foreach (var language in removed)
{ {
var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct); var count = await db.Templates.CountAsync(t => t.ServiceLanguageId == language.Id, ct)
+ await db.PushTemplates.CountAsync(t => t.ServiceLanguageId == language.Id, ct);
if (count > 0) if (count > 0)
throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation."); throw new EmailTemplateWriteException($"Cannot remove language '{language.Code}': {count} version(s) use it. Use the separate delete-with-versions confirmation.");
} }
@@ -161,10 +171,17 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
definition.Revision = Guid.NewGuid(); definition.Revision = Guid.NewGuid();
definition.Updated = DateTimeOffset.UtcNow; definition.Updated = DateTimeOffset.UtcNow;
} }
var pushDefinitions = await db.PushTemplateDefinitions.Where(d => d.ServiceId == serviceId).ToListAsync(ct);
foreach (var definition in pushDefinitions)
{
definition.Revision = Guid.NewGuid();
definition.Updated = DateTimeOffset.UtcNow;
}
} }
private Task<Dictionary<Guid, int>> VersionCountsAsync(CancellationToken ct) => db.Templates.AsNoTracking() private Task<Dictionary<Guid, int>> VersionCountsAsync(CancellationToken ct) => db.Templates.AsNoTracking()
.GroupBy(t => t.ServiceLanguageId).Select(g => new { Id = g.Key, Count = g.Count() }) .Select(t => t.ServiceLanguageId).Concat(db.PushTemplates.Select(t => t.ServiceLanguageId))
.GroupBy(id => id).Select(g => new { Id = g.Key, Count = g.Count() })
.ToDictionaryAsync(g => g.Id, g => g.Count, ct); .ToDictionaryAsync(g => g.Id, g => g.Count, ct);
private static void Validate(NotificationServiceInfo service) private static void Validate(NotificationServiceInfo service)
@@ -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;
}
}
@@ -0,0 +1,157 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF.Entities;
using Microsoft.EntityFrameworkCore;
namespace HrynCo.NotificationService.DAL.EF.Repositories;
internal sealed class PushTemplateDefinitionRepository(NotificationDbContext db, TemplateMutationRunner mutations)
: IPushTemplateDefinitionRepository
{
private IQueryable<PushTemplateDefinitionEntity> Definitions => db.PushTemplateDefinitions
.Include(d => d.Languages).ThenInclude(l => l.ServiceLanguage);
public async Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default) =>
(await Definitions.AsNoTracking().Where(d => d.ServiceId == serviceId).OrderBy(d => d.Key).ToListAsync(ct)).Select(Map).ToList();
public async Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default)
{
var entity = await Definitions.AsNoTracking().SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
return entity is null ? null : Map(entity);
}
public Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default) =>
mutations.ExecuteAsync(() => SaveCoreAsync(definition, isNew, ct), ct);
private async Task SaveCoreAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct)
{
PushTemplateValidation.Validate(definition);
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == definition.ServiceId, ct)
?? throw new EmailTemplateWriteException("Service no longer exists. Reload services.");
if (definition.Languages.Any(l => !service.Languages.Any(s => s.Code == l.LanguageCode)))
throw new EmailTemplateWriteException("Every language must exactly match this service's catalog.", false);
var entity = await LoadForSaveAsync(definition, service, isNew, ct);
entity.Variables = definition.Variables.Select(v => new PushTemplateVariableData { Name = v.Name, Required = v.Required }).ToList();
SynchronizeLanguages(entity, service, definition.Languages);
entity.Revision = Guid.NewGuid();
entity.Updated = DateTimeOffset.UtcNow;
definition.Id = entity.Id;
definition.Revision = entity.Revision;
}
public async Task<PushTemplateDefinition?> UpsertDefinitionAsync(PushTemplateDefinitionPatch patch, CancellationToken ct = default)
{
PushTemplateDefinition? saved = null;
await mutations.ExecuteAsync(async () =>
{
var service = await db.Services.Include(s => s.Languages).SingleOrDefaultAsync(s => s.Id == patch.ServiceId, ct);
if (service is null) return;
ValidatePatch(patch, service);
var current = await GetAsync(patch.ServiceId, patch.Key, ct);
var supplied = patch.Translations.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
var removed = patch.RemoveLanguageCodes.ToHashSet(StringComparer.Ordinal);
saved = new PushTemplateDefinition
{
Id = current?.Id ?? Guid.Empty, ServiceId = patch.ServiceId, Key = patch.Key,
Revision = patch.Revision ?? Guid.Empty, Variables = patch.Variables,
Languages = (current?.Languages ?? []).Where(l => !supplied.Contains(l.LanguageCode) && !removed.Contains(l.LanguageCode))
.Concat(patch.Translations).OrderBy(l => l.LanguageCode, StringComparer.Ordinal).ToList()
};
await SaveCoreAsync(saved, patch.Revision is null, ct);
}, ct);
return saved;
}
private static void ValidatePatch(PushTemplateDefinitionPatch patch, ServiceEntity service)
{
PushTemplateValidation.Validate(new PushTemplateDefinition
{
ServiceId = patch.ServiceId, Key = patch.Key, Variables = patch.Variables, Languages = patch.Translations
});
if (patch.Revision == Guid.Empty)
throw new EmailTemplateWriteException("Revision must be null for creation or a nonempty revision from the last read for update.", false);
var removed = patch.RemoveLanguageCodes;
if (removed.Any(string.IsNullOrWhiteSpace) || removed.Distinct(StringComparer.Ordinal).Count() != removed.Count)
throw new EmailTemplateWriteException("removeLanguageCodes must contain unique nonblank language codes.", false);
var supplied = patch.Translations.Select(t => t.LanguageCode).ToHashSet(StringComparer.Ordinal);
if (removed.Any(supplied.Contains))
throw new EmailTemplateWriteException("A language cannot be supplied and removed in the same request.", false);
var supported = service.Languages.Select(l => l.Code).ToHashSet(StringComparer.Ordinal);
if (supplied.Concat(removed).Any(code => !supported.Contains(code)))
throw new EmailTemplateWriteException("Every supplied or removed language must exactly match a code in this service's catalog.", false);
}
private async Task<PushTemplateDefinitionEntity> LoadForSaveAsync(PushTemplateDefinition model, ServiceEntity service, bool isNew, CancellationToken ct)
{
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == service.Id && d.Key == model.Key, ct);
if (!isNew)
{
if (entity is null || entity.Id != model.Id || entity.Revision != model.Revision)
throw new EmailTemplateWriteException("This push template changed after you opened it. Reload before saving; your entered values are retained.");
return entity;
}
if (entity is not null)
throw new EmailTemplateWriteException("A push template with this key already exists.");
var template = await db.TemplateCatalog.SingleOrDefaultAsync(t => t.ServiceId == service.Id && t.Key == model.Key, ct)
?? new TemplateEntity { Service = service, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow };
entity = new PushTemplateDefinitionEntity { Template = template, ServiceId = service.Id, Key = model.Key, Created = DateTimeOffset.UtcNow };
db.PushTemplateDefinitions.Add(entity);
return entity;
}
private void SynchronizeLanguages(PushTemplateDefinitionEntity definition, ServiceEntity service, IReadOnlyList<PushTemplateTranslation> translations)
{
var codes = translations.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
db.PushTemplates.RemoveRange(definition.Languages.Where(l => !codes.Contains(l.ServiceLanguage.Code)));
foreach (var translation in translations)
{
var entity = definition.Languages.SingleOrDefault(l => l.ServiceLanguage.Code == translation.LanguageCode);
if (entity is null)
{
entity = new PushTemplateEntity
{
Definition = definition, ServiceId = service.Id,
ServiceLanguage = service.Languages.Single(l => l.Code == translation.LanguageCode),
Title = translation.Title, Body = translation.Body, Created = DateTimeOffset.UtcNow
};
definition.Languages.Add(entity);
db.PushTemplates.Add(entity);
}
entity.Title = translation.Title;
entity.Body = translation.Body;
entity.ActionsJson = translation.ActionsJson;
entity.Updated = DateTimeOffset.UtcNow;
}
}
public async Task<bool> DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || revision == Guid.Empty)
throw new EmailTemplateWriteException("An exact key (at most 100 characters) and a nonempty revision are required.", false);
var removed = false;
await mutations.ExecuteAsync(async () => removed = await DeleteCoreAsync(serviceId, key, revision, ct), ct);
return removed;
}
private async Task<bool> DeleteCoreAsync(Guid serviceId, string key, Guid revision, CancellationToken ct)
{
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
if (entity is null) return false;
if (entity.Revision != revision)
throw new EmailTemplateWriteException("The push template revision changed. Reload before deleting.");
db.PushTemplateDefinitions.Remove(entity);
if (!await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == serviceId && d.Key == key, ct))
db.TemplateCatalog.Remove(await db.TemplateCatalog.SingleAsync(t => t.ServiceId == serviceId && t.Key == key, ct));
return true;
}
private static PushTemplateDefinition Map(PushTemplateDefinitionEntity entity) => new()
{
Id = entity.Id, ServiceId = entity.ServiceId, Key = entity.Key, Revision = entity.Revision,
Variables = entity.Variables.Select(v => new EmailTemplateVariable { Name = v.Name, Required = v.Required }).ToList(),
Languages = entity.Languages.OrderBy(l => l.ServiceLanguage.Code).Select(l => new PushTemplateTranslation
{
LanguageCode = l.ServiceLanguage.Code, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson
}).ToList()
};
}
@@ -17,7 +17,10 @@ public static class ServiceCollectionExtensions
options.UseNpgsql(connectionString)); options.UseNpgsql(connectionString));
services.AddScoped<TemplateMutationRunner>(); 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<INotificationServiceRepository, NotificationServiceRepository>();
services.AddScoped<IPushTemplateDefinitionRepository, PushTemplateDefinitionRepository>();
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>(); services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>(); services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>();
services.AddScoped<IEmailChannelRepository, EmailChannelRepository>(); services.AddScoped<IEmailChannelRepository, EmailChannelRepository>();
@@ -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)) if (string.IsNullOrWhiteSpace(serviceName))
throw new InvalidOperationException("Development seed service name is not configured."); throw new InvalidOperationException("Development seed service name is not configured.");
await SeedMailpitChannelAsync(serviceName, cancellationToken); var serviceId = await SeedServiceAsync(serviceName, cancellationToken);
await SeedServiceAsync(serviceName, cancellationToken); await SeedMailpitChannelAsync(serviceId, serviceName, cancellationToken);
foreach (EmailTemplate template in CreateTemplates(serviceName)) 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 services = await _services.GetAllAsync(ct);
var service = services.SingleOrDefault(s => s.Name == serviceName); var service = services.SingleOrDefault(s => s.Name == serviceName);
if (service?.Languages.Any(l => l.Code == DefaultLanguageCode) == true) if (service?.Languages.Any(l => l.Code == DefaultLanguageCode) == true)
return; return service.Id;
var isNew = service is null; var isNew = service is null;
service ??= new NotificationServiceInfo { Name = serviceName }; service ??= new NotificationServiceInfo { Name = serviceName };
service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = DefaultLanguageCode }]; service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = DefaultLanguageCode }];
await _services.SaveAsync(service, isNew, ct); 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( IReadOnlyList<EmailChannel> existingChannels = await _channels.GetByServiceAsync(
serviceName, cancellationToken); serviceId, cancellationToken);
if (existingChannels.Count > 0) if (existingChannels.Count > 0)
{ {
@@ -82,7 +83,7 @@ internal sealed class DevelopmentDataSeeder
var channel = new EmailChannel var channel = new EmailChannel
{ {
ServiceName = serviceName, ServiceId = serviceId,
Priority = 1, Priority = 1,
EmailChannelType = EmailChannelType.Smtp, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings Settings = new SmtpChannelSettings
@@ -13,10 +13,16 @@ public sealed class DevelopmentDataSeederTests
private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>(); private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>();
private readonly INotificationServiceRepository _services = Substitute.For<INotificationServiceRepository>(); 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] [Fact]
public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate() public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate()
{ {
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>()) _channels.GetByServiceAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<EmailChannel>()); .Returns(Array.Empty<EmailChannel>());
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>()) _templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
.Returns((EmailTemplate?)null); .Returns((EmailTemplate?)null);
@@ -39,10 +45,10 @@ public sealed class DevelopmentDataSeederTests
[Fact] [Fact]
public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt() public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt()
{ {
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>()) _channels.GetByServiceAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns([new EmailChannel .Returns([new EmailChannel
{ {
ServiceName = "TestService", ServiceId = Guid.NewGuid(),
EmailChannelType = EmailChannelType.Smtp, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings() Settings = new SmtpChannelSettings()
}]); }]);
@@ -75,7 +81,7 @@ public sealed class DevelopmentDataSeederTests
private static bool IsMailpitChannel(EmailChannel channel) private static bool IsMailpitChannel(EmailChannel channel)
{ {
return channel.ServiceName == "TestService" && return channel.ServiceId != Guid.Empty &&
channel.IsActive && channel.IsActive &&
channel.Settings is SmtpChannelSettings smtp && channel.Settings is SmtpChannelSettings smtp &&
smtp.Host == "mailpit" && smtp.Host == "mailpit" &&
@@ -12,7 +12,7 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError( string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(), CreateMessage(),
CreateContext(), CreateContext(),
SendEmailConsumer.SupportedMessageType); SupportedMessageType.SendEmailV1);
Assert.Null(error); Assert.Null(error);
} }
@@ -25,7 +25,7 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError( string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(), CreateMessage(),
context, context,
SendEmailConsumer.SupportedMessageType); SupportedMessageType.SendEmailV1);
Assert.Equal("AMQP MessageId is required.", error); Assert.Equal("AMQP MessageId is required.", error);
} }
@@ -41,18 +41,33 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError( string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(), CreateMessage(),
context, context,
SendEmailConsumer.SupportedMessageType); SupportedMessageType.SendEmailV1);
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error); 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() private static RabbitMqMessageContext CreateContext() => new()
{ {
QueueName = SendEmailConsumer.IncomingQueue, QueueName = SendEmailConsumer.IncomingQueue,
Exchange = string.Empty, Exchange = string.Empty,
RoutingKey = SendEmailConsumer.IncomingQueue, RoutingKey = SendEmailConsumer.IncomingQueue,
MessageId = Guid.NewGuid().ToString("D"), MessageId = Guid.NewGuid().ToString("D"),
MessageType = SendEmailConsumer.SupportedMessageType, MessageType = "Notification.SendEmail.v1",
CorrelationId = "correlation-id", CorrelationId = "correlation-id",
ContentType = "application/json" ContentType = "application/json"
}; };
@@ -23,12 +23,12 @@ public sealed class SendEmailServiceTests
Password = "not-a-real-secret", Password = "not-a-real-secret",
UseSsl = true, UseSsl = true,
FromEmail = "notifications@example.com", FromEmail = "notifications@example.com",
FromName = "StoreMate" FromName = "Invemory"
}; };
var channel = new EmailChannel var channel = new EmailChannel
{ {
Id = channelId, Id = channelId,
ServiceName = "StoreMate-Prod", ServiceId = Guid.NewGuid(),
Priority = 1, Priority = 1,
EmailChannelType = EmailChannelType.Smtp, EmailChannelType = EmailChannelType.Smtp,
Settings = settings, Settings = settings,
@@ -36,7 +36,7 @@ public sealed class SendEmailServiceTests
}; };
var template = new EmailTemplate var template = new EmailTemplate
{ {
ServiceName = "StoreMate-Prod", ServiceName = "Invemory-Prod",
Key = "EmailVerification", Key = "EmailVerification",
LanguageCode = "uk", LanguageCode = "uk",
Subject = "Verify {{AppName}}", Subject = "Verify {{AppName}}",
@@ -46,17 +46,21 @@ public sealed class SendEmailServiceTests
}; };
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>(); IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>()) channels.GetByServiceAsync(channel.ServiceId, Arg.Any<CancellationToken>())
.Returns([channel]); .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>(); IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>(); 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); .Returns(template);
var renderer = new EmailTemplateRenderingService(); var renderer = new EmailTemplateRenderingService();
var smtp = new RecordingSmtpEmailSender(); var smtp = new RecordingSmtpEmailSender();
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>(); INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
var service = new SendEmailService( var service = new SendEmailService(
channels, channels,
services,
usage, usage,
templates, templates,
renderer, renderer,
@@ -71,12 +75,12 @@ public sealed class SendEmailServiceTests
}, },
Data = new SendEmailMessageData Data = new SendEmailMessageData
{ {
ServiceName = "StoreMate-Prod", ServiceName = "Invemory-Prod",
TemplateKey = "EmailVerification", TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com", RecipientEmail = "owner@example.com",
RecipientName = "Owner", RecipientName = "Owner",
LanguageCode = "uk", 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.Equal(1, smtp.SendCount);
Assert.Same(settings, smtp.Settings); 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@example.com", smtp.RecipientEmail);
Assert.Equal("Owner", smtp.RecipientName); Assert.Equal("Owner", smtp.RecipientName);
await usage.Received(1).IncrementUsageAsync( await usage.Received(1).IncrementUsageAsync(
@@ -0,0 +1,88 @@
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
{
[Theory]
[InlineData(1)]
[InlineData(2)]
public async Task ResolvesCurrentServiceNameToActiveChannelAndUsesOnlyServerCredentials(int version)
{
var services = Substitute.For<INotificationServiceRepository>();
var channels = Substitute.For<IPushChannelRepository>();
var client = Substitute.For<IFirebasePushClient>();
var templates = Substitute.For<IPushTemplateDefinitionRepository>();
templates.GetAsync(Arg.Any<Guid>(), "Test", Arg.Any<CancellationToken>()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] });
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory-Renamed" };
var channel = new PushChannel { Id = Guid.NewGuid(), ServiceId = service.Id, FirebaseProjectId = "test-project", IsActive = true };
services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
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<RenderedPushMessageData>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(new PushDeliveryOutcome(null));
var data = version == 1 ? Data(service.Name) with { Version = 1, Title = "Legacy title", Body = "Legacy body", TemplateKey = null, TemplateData = null } : Data(service.Name);
var result = await new FcmPushSender(services, channels, client, templates).SendAsync(Guid.NewGuid(), data, CancellationToken.None);
Assert.Null(result.Error);
await client.Received(1).SendAsync(Arg.Any<Guid>(), Arg.Any<RenderedPushMessageData>(),
Arg.Is<FirebaseServiceAccountCredentials>(c => c.ProjectId == "test-project" && c.ClientEmail == "test@example.invalid"), Arg.Any<CancellationToken>());
await templates.Received(version == 1 ? 0 : 1).GetAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
if (version == 1)
await client.Received().SendAsync(Arg.Any<Guid>(), Arg.Is<RenderedPushMessageData>(m => m.Title == "Legacy title" && m.Body == "Legacy body" && m.Buttons.Count == 0), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
}
[Theory]
[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 templates = Substitute.For<IPushTemplateDefinitionRepository>();
templates.GetAsync(Arg.Any<Guid>(), "Test", Arg.Any<CancellationToken>()).Returns(new PushTemplateDefinition { Key = "Test", Languages = [new PushTemplateTranslation { LanguageCode = "en", Title = "Test", Body = "Test" }] });
var service = new NotificationServiceInfo { Id = Guid.NewGuid(), Name = "Invemory" };
if (exists) services.GetByNameAsync(service.Name, Arg.Any<CancellationToken>()).Returns(service);
if (active)
{
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, templates).SendAsync(Guid.NewGuid(), Data(service.Name), CancellationToken.None);
Assert.NotNull(result.Error);
Assert.DoesNotContain("secret-must-not-leak", result.Error);
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
}
[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, Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, Token = "test-device-token",
TokenHash = new string('a', 64), ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10)
};
}
@@ -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(Arg.Is<PushChannelTestMessage>(m => m.Token == "test-token" && m.Version == 1), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await firebase.Received(send ? 0 : 1).CheckAuthorizationAsync(Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>());
await channels.DidNotReceiveWithAnyArgs().SaveAsync(default!, default!, default, default);
}
[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<PushChannelTestMessage>(), Arg.Any<FirebaseServiceAccountCredentials>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PushDeliveryOutcome>(new PushRetryException(TimeSpan.FromSeconds(30))));
var outcome = await new PushChannelTestService(channels, firebase).TestAsync(new PushChannelTestSettings
{ FirebaseProjectId = "test-project", CredentialsJson = Credentials(), RecipientToken = "test-token" }, true, default);
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<PushChannelTestMessage>(), 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", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, TokenHash = new string('a', 64)
}
};
await service.CompleteFailureAsync(id, message, default);
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,105 @@
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.Push;
using Hrynco.RabbitMq;
using NSubstitute;
namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class PushPayloadVersionTests
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public void OriginalPayloadDeserializesWithoutTemplateFieldsAndKeepsExactLegacyOutput(bool explicitVersion)
{
var json = JsonSerializer.SerializeToNode(new
{
registrationId = Guid.NewGuid(), bindingId = Guid.NewGuid(), userId = Guid.NewGuid(),
serviceName = "Invemory", title = "Original title", body = "Original body",
token = "device-token", tokenHash = new string('a', 64), expiresAt = DateTimeOffset.UtcNow.AddMinutes(5)
})!;
if (explicitVersion) json["version"] = 1;
var data = json.Deserialize<SendPushMessageData>(new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
var message = new SendPushMessage { Data = data, CorrelationContext = new CorrelationContext { CorrelationId = Guid.NewGuid().ToString() } };
Assert.True(PushMessageValidator.IsValid(message));
Assert.Null(data.TemplateData);
var id = Guid.NewGuid();
var payload = FirebasePushClient.BuildPayload(id, new RenderedPushMessageData(data, data.Title!, data.Body!, null, []));
Assert.Equal(7, payload.Count);
Assert.Equal("1", payload["version"]);
Assert.Equal("/home", payload["route"]);
Assert.Equal(id.ToString("D"), payload["messageId"]);
Assert.Equal(data.BindingId.ToString("D"), payload["bindingId"]);
Assert.Equal("Original title", payload["title"]);
Assert.Equal("Original body", payload["body"]);
Assert.False(payload.ContainsKey("actions"));
}
[Theory]
[InlineData(0)]
[InlineData(3)]
[InlineData(-1)]
public async Task UnsupportedVersionNeverFallsBackOrContactsFirebase(int version)
{
var data = Data() with { Version = version, Title = "Legacy", Body = "Legacy" };
Assert.False(PushMessageValidator.IsValidContent(data));
var client = Substitute.For<IFirebasePushClient>();
var sender = new FcmPushSender(Substitute.For<INotificationServiceRepository>(), Substitute.For<IPushChannelRepository>(), client, Substitute.For<IPushTemplateDefinitionRepository>());
var result = await sender.SendAsync(Guid.NewGuid(), data, default);
Assert.Contains("Unsupported push payload version", result.Error);
await client.DidNotReceiveWithAnyArgs().SendAsync(default, default!, default!, default);
Assert.Throws<ArgumentOutOfRangeException>(() => FirebasePushClient.BuildPayload(Guid.NewGuid(), new RenderedPushMessageData(data, "Title", "Body", null, [])));
}
[Fact]
public void SectionActionHasNoObjectIdAndVersionTwoNeverContainsLegacyRoute()
{
var template = new PushTemplateDefinition { Key = "Section", Languages = [new PushTemplateTranslation
{
LanguageCode = "uk", Title = "Зміни", Body = "Перегляньте зміни",
ActionsJson = "{\"defaultAction\":\"openBasket\",\"buttons\":[{\"action\":\"openBasket\",\"label\":\"Перейти в розділ\"}]}"
}] };
var data = Data();
var rendered = PushTemplateRenderer.Render(template, data);
Assert.Null(Assert.Single(rendered.Buttons).ObjectId);
var payload = FirebasePushClient.BuildPayload(Guid.NewGuid(), rendered);
Assert.Equal("2", payload["version"]);
Assert.False(payload.ContainsKey("route"));
using var actions = JsonDocument.Parse(payload["actions"]);
var button = actions.RootElement.GetProperty("buttons")[0];
Assert.Equal("openBasket", button.GetProperty("action").GetString());
Assert.Equal("Перейти в розділ", button.GetProperty("label").GetString());
Assert.False(button.TryGetProperty("objectId", out _));
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(template, data with { TemplateData = data.TemplateData! with { Actions = new Dictionary<string, PushActionMetadata>() } }));
}
[Fact]
public void TestSendBuildsActualButtonPayloadWithRealBindingAndPreservesSimpleLegacyTest()
{
var binding = Guid.NewGuid();
var test = new PushChannelTestMessage { Token = "test-token", Version = 2, BindingId = binding, ActionKey = "openBasket", ButtonLabel = "Перейти в розділ" };
var current = FirebasePushClient.BuildTestMessage(test);
Assert.Null(current.Notification);
Assert.Equal("2", current.Data["version"]);
Assert.Equal(binding.ToString("D"), current.Data["bindingId"]);
using var actions = JsonDocument.Parse(current.Data["actions"]);
Assert.Equal("openBasket", actions.RootElement.GetProperty("buttons")[0].GetProperty("action").GetString());
Assert.False(actions.RootElement.GetProperty("buttons")[0].TryGetProperty("objectId", out _));
var legacy = FirebasePushClient.BuildTestMessage(test with { Version = 1, BindingId = null });
Assert.NotNull(legacy.Notification);
Assert.True(legacy.Data is null || legacy.Data.Count == 0);
Assert.NotNull((test with { BindingId = null }).Validate());
Assert.NotNull((test with { Version = 9 }).Validate());
}
private static SendPushMessageData Data() => new()
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(), ServiceName = "Invemory",
Version = 2, TemplateKey = "Section", LanguageCode = "uk", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata> { ["openBasket"] = new() } }
};
}
@@ -0,0 +1,90 @@
using System.Text.Json;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Worker.Services.Push;
namespace HrynCo.NotificationService.Services.Tests.Push;
public sealed class PushTemplateRendererTests
{
[Fact]
public void RendersLocalizedLabelsAndOpaqueObjectIdsWithoutReinterpretingValues()
{
var result = PushTemplateRenderer.Render(Template(), Message());
Assert.Equal("Hello {{Injected}}", result.Title);
var button = Assert.Single(result.Buttons);
Assert.Equal("View {{Injected}}", button.Label);
Assert.Equal("need-42", button.ObjectId);
Assert.Equal("open", result.DefaultAction!.Action);
using var payload = JsonDocument.Parse(FirebasePushClient.BuildPayload(Guid.NewGuid(), result)["actions"]);
Assert.Equal("need-42", payload.RootElement.GetProperty("buttons")[0].GetProperty("objectId").GetString());
Assert.Equal("open", payload.RootElement.GetProperty("defaultAction").GetProperty("action").GetString());
}
[Fact]
public void UsesEnglishFallbackAndRejectsMissingFallback()
{
var message = Message() with { LanguageCode = "uk" };
Assert.Equal("Hello {{Injected}}", PushTemplateRenderer.Render(Template(), message).Title);
var template = Template();
template.Languages = [];
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(template, message));
}
[Fact]
public void MissingBindingOrConfiguredActionFailsInsteadOfSendingIncompleteNotification()
{
var message = Message();
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(Template(), message with
{
TemplateData = message.TemplateData! with { Bindings = new Dictionary<string, string>() }
}));
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateRenderer.Render(Template(), message with
{
TemplateData = message.TemplateData! with { Actions = new Dictionary<string, PushActionMetadata>() }
}));
}
[Fact]
public void TruncationKeepsUnicodeTextElementsIntact()
{
var message = Message() with
{
TemplateData = Message().TemplateData! with { Bindings = new Dictionary<string, string> { ["Name"] = string.Concat(Enumerable.Repeat("😀", 100)) } }
};
var result = PushTemplateRenderer.Render(Template(), message);
Assert.True(result.Title.Length <= 100);
Assert.EndsWith("…", result.Title);
Assert.False(char.IsHighSurrogate(result.Title[^2]));
}
[Theory]
[InlineData("{\"buttons\":null}")]
[InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"\"}]}")]
[InlineData("{\"buttons\":[{\"action\":\"open\",\"label\":\"Open\"},{\"action\":\"open\",\"label\":\"Again\"}]}")]
[InlineData("{\"defaultAction\":\"https://example.invalid\"}")]
[InlineData("{\"unknown\":true}")]
public void RejectsInvalidActionDefinitions(string json) =>
Assert.Throws<EmailTemplateWriteException>(() => PushTemplateActionValidation.Parse(json));
private static PushTemplateDefinition Template() => new()
{
Key = "Shared", Variables = [new EmailTemplateVariable { Name = "Name", Required = true }],
Languages = [new PushTemplateTranslation
{
LanguageCode = "en", Title = "Hello {{Name}}", Body = "Shared item",
ActionsJson = "{\"defaultAction\":\"open\",\"buttons\":[{\"action\":\"open\",\"label\":\"View {{Name}}\"}]}"
}]
};
private static SendPushMessageData Message() => new()
{
RegistrationId = Guid.NewGuid(), BindingId = Guid.NewGuid(), UserId = Guid.NewGuid(),
ServiceName = "Example", Version = 2, TemplateKey = "Shared", LanguageCode = "en", ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
TemplateData = new PushTemplateData
{
Bindings = new Dictionary<string, string> { ["Name"] = "{{Injected}}" },
Actions = new Dictionary<string, PushActionMetadata> { ["open"] = new() { ObjectId = "need-42" } }
}
};
}
@@ -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", Version = 2, TemplateKey = "Test", LanguageCode = "en", TemplateData = new PushTemplateData { Bindings = new Dictionary<string, string>(), Actions = new Dictionary<string, PushActionMetadata>() }, Token = "test-device-token",
TokenHash = new string('a', 64), ExpiresAt = now.AddMinutes(10)
}
};
var context = new RabbitMqMessageContext
{
QueueName = "notification.send-push", RoutingKey = "notification.send-push", Exchange = "",
MessageId = id, MessageType = wireType
};
Assert.Equal(expected, SendPushDeliveryValidator.IsValid(message, context));
}
}
@@ -5,7 +5,7 @@ using HrynCo.NotificationService.Services.Core;
namespace HrynCo.NotificationService.Services.EmailChannels.Create; namespace HrynCo.NotificationService.Services.EmailChannels.Create;
public sealed record CreateEmailChannelCommand( public sealed record CreateEmailChannelCommand(
string ServiceName, Guid ServiceId,
int Priority, int Priority,
EmailChannelType ChannelType, EmailChannelType ChannelType,
EmailChannelSettings Settings, EmailChannelSettings Settings,
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Providers; using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
@@ -11,21 +10,27 @@ internal sealed class CreateEmailChannelHandler
: RequestHandler<CreateEmailChannelCommand, ServiceResult<Guid>> : RequestHandler<CreateEmailChannelCommand, ServiceResult<Guid>>
{ {
private readonly IEmailChannelRepository _channels; private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public CreateEmailChannelHandler( public CreateEmailChannelHandler(
IContextualSerilogLogger<CreateEmailChannelCommand> logger, IContextualSerilogLogger<CreateEmailChannelCommand> logger,
IEmailChannelRepository channels) IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger) : base(logger)
{ {
_channels = channels; _channels = channels;
_services = services;
} }
protected override async Task<ServiceResult<Guid>> DoOnHandle( protected override async Task<ServiceResult<Guid>> DoOnHandle(
CreateEmailChannelCommand request, CancellationToken cancellationToken) 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 var channel = new EmailChannel
{ {
ServiceName = request.ServiceName, ServiceId = request.ServiceId,
Priority = request.Priority, Priority = request.Priority,
EmailChannelType = request.ChannelType, EmailChannelType = request.ChannelType,
Settings = request.Settings, Settings = request.Settings,
@@ -10,19 +10,25 @@ internal sealed class GetEmailChannelsHandler
: RequestHandler<GetEmailChannelsQuery, ServiceResult<IReadOnlyList<EmailChannel>>> : RequestHandler<GetEmailChannelsQuery, ServiceResult<IReadOnlyList<EmailChannel>>>
{ {
private readonly IEmailChannelRepository _channels; private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public GetEmailChannelsHandler( public GetEmailChannelsHandler(
IContextualSerilogLogger<GetEmailChannelsQuery> logger, IContextualSerilogLogger<GetEmailChannelsQuery> logger,
IEmailChannelRepository channels) IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger) : base(logger)
{ {
_channels = channels; _channels = channels;
_services = services;
} }
protected override async Task<ServiceResult<IReadOnlyList<EmailChannel>>> DoOnHandle( protected override async Task<ServiceResult<IReadOnlyList<EmailChannel>>> DoOnHandle(
GetEmailChannelsQuery request, CancellationToken cancellationToken) 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); return Success(channels);
} }
} }
@@ -7,6 +7,7 @@ namespace HrynCo.NotificationService.Services.EmailChannels.Update;
public sealed record UpdateEmailChannelCommand( public sealed record UpdateEmailChannelCommand(
Guid Id, Guid Id,
Guid ServiceId,
int Priority, int Priority,
EmailChannelSettings Settings, EmailChannelSettings Settings,
int? DailyLimit, int? DailyLimit,
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories; using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core; using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging; using HrynCo.NotificationService.Services.Logging;
@@ -10,13 +9,16 @@ internal sealed class UpdateEmailChannelHandler
: RequestHandler<UpdateEmailChannelCommand, ServiceResult<Unit>> : RequestHandler<UpdateEmailChannelCommand, ServiceResult<Unit>>
{ {
private readonly IEmailChannelRepository _channels; private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public UpdateEmailChannelHandler( public UpdateEmailChannelHandler(
IContextualSerilogLogger<UpdateEmailChannelCommand> logger, IContextualSerilogLogger<UpdateEmailChannelCommand> logger,
IEmailChannelRepository channels) IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger) : base(logger)
{ {
_channels = channels; _channels = channels;
_services = services;
} }
protected override async Task<ServiceResult<Unit>> DoOnHandle( protected override async Task<ServiceResult<Unit>> DoOnHandle(
@@ -27,6 +29,10 @@ internal sealed class UpdateEmailChannelHandler
if (channel is null) if (channel is null)
return Failure<Unit>("Email channel not found.", ServiceErrorCode.NotFound); 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.Priority = request.Priority;
channel.Settings = request.Settings; channel.Settings = request.Settings;
channel.DailyLimit = request.DailyLimit; 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);
}
}
}
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
using Unit = HrynCo.NotificationService.Services.Core.Unit;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record DeletePushTemplateDefinitionCommand(Guid ServiceId, string Key, Guid Revision) : IRequest<ServiceResult<Unit>>;
@@ -0,0 +1,25 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class DeletePushTemplateDefinitionHandler(
IContextualSerilogLogger<DeletePushTemplateDefinitionCommand> logger, IPushTemplateDefinitionRepository repository)
: RequestHandler<DeletePushTemplateDefinitionCommand, ServiceResult<Unit>>(logger)
{
protected override async Task<ServiceResult<Unit>> DoOnHandle(DeletePushTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var removed = await repository.DeleteAsync(request.ServiceId, request.Key, request.Revision, cancellationToken);
return removed ? Success(Unit.Value) : Failure<Unit>("Template definition not found.", ServiceErrorCode.NotFound);
}
catch (EmailTemplateWriteException error)
{
return Failure<Unit>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -0,0 +1,19 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetPushTemplateDefinitionHandler(
IContextualSerilogLogger<GetPushTemplateDefinitionQuery> logger, IPushTemplateDefinitionRepository definitions)
: RequestHandler<GetPushTemplateDefinitionQuery, ServiceResult<PushTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<PushTemplateDefinitionDto>> DoOnHandle(GetPushTemplateDefinitionQuery request, CancellationToken cancellationToken)
{
var definition = await definitions.GetAsync(request.ServiceId, request.Key, cancellationToken);
return definition is null
? Failure<PushTemplateDefinitionDto>("Template definition not found.", ServiceErrorCode.NotFound)
: Success(PushTemplateDefinitionMapping.Map(definition));
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetPushTemplateDefinitionQuery(Guid ServiceId, string Key) : IRequest<ServiceResult<PushTemplateDefinitionDto>>;
@@ -0,0 +1,20 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class GetServicePushTemplateDefinitionsHandler(
IContextualSerilogLogger<GetServicePushTemplateDefinitionsQuery> logger,
INotificationServiceRepository services, IPushTemplateDefinitionRepository definitions)
: RequestHandler<GetServicePushTemplateDefinitionsQuery, ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>>(logger)
{
protected override async Task<ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>> DoOnHandle(GetServicePushTemplateDefinitionsQuery request, CancellationToken cancellationToken)
{
if (await services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<IReadOnlyList<PushTemplateDefinitionDto>>("Service not found.", ServiceErrorCode.NotFound);
var all = await definitions.GetDefinitionsAsync(request.ServiceId, cancellationToken);
return Success<IReadOnlyList<PushTemplateDefinitionDto>>(all.Select(PushTemplateDefinitionMapping.Map).ToList());
}
}
@@ -0,0 +1,6 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record GetServicePushTemplateDefinitionsQuery(Guid ServiceId) : IRequest<ServiceResult<IReadOnlyList<PushTemplateDefinitionDto>>>;
@@ -0,0 +1,4 @@
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record PushTemplateDefinitionDto(Guid Id, Guid ServiceId, string Key, Guid Revision,
IReadOnlyList<TemplateVariableDto> Variables, IReadOnlyList<PushTemplateTranslationDto> Translations);
@@ -0,0 +1,11 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal static class PushTemplateDefinitionMapping
{
public static PushTemplateDefinitionDto Map(PushTemplateDefinition model) => new(model.Id, model.ServiceId,
model.Key, model.Revision, model.Variables.Select(v => new TemplateVariableDto(v.Name, v.Required)).ToList(),
model.Languages.Select(l => new PushTemplateTranslationDto(l.LanguageCode, l.Title, l.Body,
PushTemplateActionValidation.Parse(l.ActionsJson))).ToList());
}
@@ -0,0 +1,5 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record PushTemplateTranslationDto(string LanguageCode, string Title, string Body, PushTemplateActions Actions);
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
public sealed record UpsertPushTemplateDefinitionCommand(PushTemplateDefinitionPatch Patch) : IRequest<ServiceResult<PushTemplateDefinitionDto>>;
@@ -0,0 +1,27 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.TemplateDefinitions;
internal sealed class UpsertPushTemplateDefinitionHandler(
IContextualSerilogLogger<UpsertPushTemplateDefinitionCommand> logger, IPushTemplateDefinitionRepository definitions)
: RequestHandler<UpsertPushTemplateDefinitionCommand, ServiceResult<PushTemplateDefinitionDto>>(logger)
{
protected override async Task<ServiceResult<PushTemplateDefinitionDto>> DoOnHandle(UpsertPushTemplateDefinitionCommand request, CancellationToken cancellationToken)
{
try
{
var definition = await definitions.UpsertDefinitionAsync(request.Patch, cancellationToken);
return definition is null
? Failure<PushTemplateDefinitionDto>("Service not found.", ServiceErrorCode.NotFound)
: Success(PushTemplateDefinitionMapping.Map(definition));
}
catch (EmailTemplateWriteException error)
{
return Failure<PushTemplateDefinitionDto>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -67,7 +67,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
var url = $"/admin/services/{context.ServiceId}/delete"; var url = $"/admin/services/{context.ServiceId}/delete";
var blocked = await client.GetStringAsync(url); var blocked = await client.GetStringAsync(url);
Assert.Contains("1 template definition(s)", blocked); 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); var fields = TemplateEditorTestFields.Read(blocked);
fields["ConfirmDelete"] = "true"; fields["ConfirmDelete"] = "true";
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync()); Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
@@ -102,7 +102,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
await using var context = await DefinitionApiTestContext.CreateAsync(fixture); await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var root = $"/admin/services/{context.ServiceId}"; var root = $"/admin/services/{context.ServiceId}";
var templates = await context.Client.GetStringAsync(root); var templates = await context.Client.GetStringAsync(root);
Assert.Contains("No email templates for this service yet.", templates); Assert.Contains("No templates for this service yet.", templates);
Assert.Contains("Create First Template", templates); Assert.Contains("Create First Template", templates);
Assert.Contains(root + "/templates/create", templates); Assert.Contains(root + "/templates/create", templates);
Assert.Contains("template-key-filter", templates); Assert.Contains("template-key-filter", templates);
@@ -129,7 +129,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
Assert.Contains("Could not load templates", html); Assert.Contains("Could not load templates", html);
Assert.Contains("Templates are unavailable", html); Assert.Contains("Templates are unavailable", html);
Assert.DoesNotContain("Create First Template", html); Assert.DoesNotContain("Create First Template", html);
Assert.DoesNotContain("No email templates for this service yet.", html); Assert.DoesNotContain("No templates for this service yet.", html);
} }
private static void AssertConfirmationReset(string html) => private static void AssertConfirmationReset(string html) =>
@@ -43,7 +43,7 @@ public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : ICla
using var scope = context.App.Services.CreateScope(); using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel 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" } Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
}); });
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync(); await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
@@ -0,0 +1,34 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminSmtpEditorViewTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task ExistingChannelTestIncludesIdWithoutRenderingSavedPassword()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceId = context.ServiceId,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Username = "test-user",
Password = "saved-secret-must-not-render", FromEmail = "sender@example.invalid"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var html = await context.Client.GetStringAsync($"/admin/channels/{channel.Id}");
Assert.Contains($"value=\"{channel.Id}\"", html);
Assert.Contains("const channelId = form.querySelector('[name=\"Id\"]').value", html);
Assert.DoesNotContain("saved-secret-must-not-render", html);
}
}
@@ -0,0 +1,139 @@
using System.Net;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task BlankPasswordKeepsSavedValueWhenOnlySenderChanges()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var saved = await ReadSettings(context, channel.Id);
Assert.Equal("saved-test-secret", saved.Password);
Assert.Equal("Updated sender", saved.FromName);
}
[Theory]
[InlineData("Host", "other.example.invalid")]
[InlineData("Port", "2525")]
[InlineData("Username", "other-user")]
[InlineData("UseSsl", "false")]
public async Task ChangedConnectionWithBlankPasswordIsRejected(string field, string value)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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;
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("Enter the password to save changed SMTP connection settings.", html);
Assert.DoesNotContain("saved-test-secret", html);
var saved = await ReadSettings(context, channel.Id);
Assert.Equal("saved-test-secret", saved.Password);
Assert.Equal("smtp.example.invalid", saved.Host);
}
[Fact]
public async Task ExplicitPasswordCanReplaceSavedValue()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var saved = await ReadSettings(context, channel.Id);
Assert.Equal("replacement-test-secret", saved.Password);
Assert.Equal("other.example.invalid", saved.Host);
}
[Fact]
public async Task NewChannelStillRequiresPassword()
{
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["ServiceId"] = context.ServiceId.ToString();
fields["Host"] = "smtp.example.invalid";
fields["Username"] = "test-user";
fields["FromEmail"] = "sender@example.invalid";
fields["FromName"] = "Sender";
fields["Password"] = "";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Contains("The Password field is required.", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task InvalidFormDoesNotRenderEnteredPassword()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
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["Password"] = "replacement-test-secret";
fields["FromEmail"] = "invalid-address";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
var html = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.DoesNotContain("replacement-test-secret", html);
Assert.Equal("saved-test-secret", (await ReadSettings(context, channel.Id)).Password);
}
private static async Task<EmailChannel> SeedChannel(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Port = 587, Username = "test-user",
Password = "saved-test-secret", UseSsl = true,
FromEmail = "sender@example.invalid", FromName = "Original sender"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
return channel;
}
private static async Task<SmtpChannelSettings> ReadSettings(DefinitionApiTestContext context, Guid id)
{
using var scope = context.App.Services.CreateScope();
var channel = await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetByIdAsync(id);
return Assert.IsType<SmtpChannelSettings>(channel?.Settings);
}
}
@@ -0,0 +1,112 @@
using System.Reflection;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.Web.Controllers.Admin;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class AdminSmtpTestControllerTests
{
private static readonly Guid ChannelId = Guid.NewGuid();
[Fact]
public async Task BlankPasswordUsesSavedSecretAndCurrentUnsavedSender()
{
var (controller, recorder) = CreateController();
var response = await controller.TestSmtp(Request(), CancellationToken.None);
Assert.True(Succeeded(response));
Assert.Equal("saved-test-secret", recorder.SentCommand?.Password);
Assert.Equal("changed@example.invalid", recorder.SentCommand?.FromEmail);
Assert.Equal("Changed sender", recorder.SentCommand?.FromName);
Assert.Equal(1, recorder.LookupCount);
Assert.DoesNotContain("saved-test-secret", JsonSerializer.Serialize(((OkObjectResult)response).Value));
}
[Fact]
public async Task ExplicitPasswordOverridesSavedWithoutLookup()
{
var (controller, recorder) = CreateController();
var response = await controller.TestSmtp(Request() with { Password = "typed-test-secret" }, CancellationToken.None);
Assert.True(Succeeded(response));
Assert.Equal("typed-test-secret", recorder.SentCommand?.Password);
Assert.Equal(0, recorder.LookupCount);
}
[Theory]
[InlineData("host")]
[InlineData("port")]
[InlineData("username")]
[InlineData("ssl")]
public async Task ChangedConnectionRequiresExplicitPassword(string field)
{
var (controller, recorder) = CreateController();
var request = field switch
{
"host" => Request() with { Host = "other.example.invalid" },
"port" => Request() with { Port = 2525 },
"username" => Request() with { Username = "other-user" },
_ => Request() with { UseSsl = false }
};
var response = await controller.TestSmtp(request, CancellationToken.None);
Assert.False(Succeeded(response));
Assert.Null(recorder.SentCommand);
}
[Fact]
public async Task UnknownChannelDoesNotSend()
{
var (controller, recorder) = CreateController();
recorder.Channel = null;
var response = await controller.TestSmtp(Request(), CancellationToken.None);
Assert.False(Succeeded(response));
Assert.Null(recorder.SentCommand);
}
[Fact]
public async Task NewChannelKeepsAdHocTestBehavior()
{
var (controller, recorder) = CreateController();
var response = await controller.TestSmtp(Request() with { ChannelId = null }, CancellationToken.None);
Assert.True(Succeeded(response));
Assert.Equal(string.Empty, recorder.SentCommand?.Password);
Assert.Equal(0, recorder.LookupCount);
}
private static (AdminChannelsController Controller, RecordingAdminSmtpMediator Recorder) CreateController()
{
var mediator = DispatchProxy.Create<IMediator, RecordingAdminSmtpMediator>();
var recorder = (RecordingAdminSmtpMediator)mediator;
recorder.Channel = new EmailChannel
{
Id = ChannelId,
ServiceId = Guid.NewGuid(),
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
Host = "smtp.example.invalid", Port = 587,
Username = "test-user", Password = "saved-test-secret", UseSsl = true
}
};
return (new AdminChannelsController(mediator, null!), recorder);
}
private static TestSmtpRequest Request() => new(
"smtp.example.invalid", 587, "test-user", "", true,
"changed@example.invalid", "Changed sender", "recipient@example.invalid", ChannelId);
private static bool Succeeded(IActionResult response)
{
var body = JsonSerializer.Serialize(((OkObjectResult)response).Value);
return JsonDocument.Parse(body).RootElement.GetProperty("success").GetBoolean();
}
}
@@ -8,7 +8,7 @@ public sealed class AdminTemplatesIndexViewTests
string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml")); string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml"));
Assert.Contains("asp-route-serviceId=\"@serviceId\"", view); Assert.Contains("asp-route-serviceId=\"@serviceId\"", view);
Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view); Assert.Contains("asp-route-keyFilter=\"@keyFilter\"", view);
Assert.Contains("new { serviceId, definitionId = template.Id, keyFilter }", view); Assert.Contains("new { serviceId, definitionId = email.Id, keyFilter }", view);
Assert.DoesNotContain("/admin/templates", view); Assert.DoesNotContain("/admin/templates", view);
} }
} }
@@ -0,0 +1,153 @@
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class ChannelTemplateModelTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task MigrationPreservesEmailAndSupportsAllChannelCombinations()
{
var connection = await fixture.CreateDatabaseAsync(legacy: true);
var originalId = await TemplateDatabaseFixture.InsertLegacyAsync(connection, "en", "[{\"name\":\"Name\",\"required\":true}]");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
await db.Database.MigrateAsync();
await db.Database.MigrateAsync();
Assert.False(db.Database.HasPendingModelChanges());
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var emails = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
var original = await emails.GetAsync("TestService", "Welcome", "en");
Assert.Equal(originalId, original!.Id);
Assert.True(Assert.Single(original.Variables).Required);
await SeedPushAsync(connection, "Welcome");
await SeedPushAsync(connection, "PushOnly");
Assert.Null(await emails.GetAsync("TestService", "PushOnly", "en"));
var definitions = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "EmailOnly" }, true);
await definitions.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = "TestService", Key = "PushOnly" }, true);
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF (SELECT count(*) FROM templates) <> 3 OR (SELECT count(*) FROM push_template_definitions) <> 2 THEN
RAISE EXCEPTION 'Unexpected shared catalog or channel count';
END IF;
IF (SELECT variables FROM email_template_definitions WHERE key = 'Welcome') =
(SELECT variables FROM push_template_definitions WHERE key = 'Welcome') THEN
RAISE EXCEPTION 'Channel variables must remain independent';
END IF;
END $$;
""");
}
[Fact]
public async Task DeletingEmailRetainsPushAndBlocksServiceDeletion()
{
var connection = await fixture.CreateDatabaseAsync();
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var emails = scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>();
var email = new EmailTemplateDefinition { ServiceName = "TestService", Key = "Shared" };
await emails.SaveDefinitionAsync(email, true);
await SeedPushAsync(connection, "Shared");
Assert.True(await emails.DeleteDefinitionAsync(email.ServiceId == Guid.Empty
? (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync("TestService"))!.Id
: email.ServiceId, email.Key, email.Revision));
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM templates WHERE key = 'Shared') OR
NOT EXISTS (SELECT 1 FROM push_templates) THEN
RAISE EXCEPTION 'Email deletion removed push';
END IF;
END $$;
""");
using var readScope = provider.CreateScope();
var services = readScope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetByNameAsync("TestService"))!;
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.DeleteAsync(service.Id, service.Revision));
}
[Fact]
public async Task LanguageDeletionCountsBothChannelsAndRetainsDefinitions()
{
var connection = await fixture.CreateDatabaseAsync();
await SeedPushAsync(connection, "PushOnly");
using var provider = Provider(connection);
using var scope = provider.CreateScope();
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetByNameAsync("TestService"))!;
var language = service.Languages.Single(l => l.Code == "en");
Assert.Equal(1, language.VersionCount);
service.Languages = service.Languages.Where(l => l.Id != language.Id).ToList();
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.SaveAsync(service, false));
await services.RemoveLanguageAsync(service.Id, language.Id, 1, service.Revision);
await TemplateDatabaseFixture.ExecuteAsync(connection, """
DO $$ BEGIN
IF EXISTS (SELECT 1 FROM push_templates) OR
NOT EXISTS (SELECT 1 FROM push_template_definitions WHERE key = 'PushOnly') THEN
RAISE EXCEPTION 'Language deletion did not retain the empty push definition';
END IF;
END $$;
""");
}
[Fact]
public async Task DatabaseRejectsDuplicatePushLanguageAndForeignServiceLanguage()
{
var connection = await fixture.CreateDatabaseAsync();
await SeedPushAsync(connection, "PushOnly");
await AssertSqlStateAsync(connection, "23505", """
INSERT INTO push_templates
SELECT gen_random_uuid(), definition_id, service_id, service_language_id, title, body, created, updated
FROM push_templates LIMIT 1;
""");
await TemplateDatabaseFixture.ExecuteAsync(connection, """
INSERT INTO services (id, name, revision, created) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Other', gen_random_uuid(), now());
INSERT INTO service_languages (id, service_id, code, created) VALUES
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'en', now());
""");
await AssertSqlStateAsync(connection, "23503", """
UPDATE push_templates SET service_language_id = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
""");
}
[Fact]
public async Task RollbackRefusesToDiscardPushDefinitions()
{
var connection = await fixture.CreateDatabaseAsync();
await SeedPushAsync(connection, "PushOnly");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var error = await Assert.ThrowsAsync<PostgresException>(() => db.GetService<IMigrator>()
.MigrateAsync("20261024080457_AddPushChannels"));
Assert.Contains("Cannot roll back channel templates", error.MessageText);
await db.Database.MigrateAsync();
}
private static Task SeedPushAsync(string connection, string key) => TemplateDatabaseFixture.ExecuteAsync(connection, $"""
INSERT INTO templates (id, service_id, key, created)
SELECT gen_random_uuid(), id, '{key}', now() FROM services WHERE name = 'TestService'
ON CONFLICT (service_id, key) DO NOTHING;
INSERT INTO push_template_definitions (id, service_id, key, revision, variables, created)
SELECT gen_random_uuid(), service_id, key, gen_random_uuid(), '[]'::jsonb, now()
FROM templates WHERE key = '{key}';
INSERT INTO push_templates (id, definition_id, service_id, service_language_id, title, body, created)
SELECT gen_random_uuid(), d.id, d.service_id, l.id, 'Title', 'Body', now()
FROM push_template_definitions d JOIN service_languages l ON l.service_id = d.service_id
WHERE d.key = '{key}' AND l.code = 'en';
""");
private static ServiceProvider Provider(string connection) =>
new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
private static async Task AssertSqlStateAsync(string connection, string state, string sql)
{
var error = await Assert.ThrowsAsync<PostgresException>(() => TemplateDatabaseFixture.ExecuteAsync(connection, sql));
Assert.Equal(state, error.SqlState);
}
}
@@ -22,8 +22,8 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
{ {
(Url: "/admin/services/create", Title: "Create Service", Form: "serviceForm"), (Url: "/admin/services/create", Title: "Create Service", Form: "serviceForm"),
(Url: root + "/edit", Title: "Edit Service", Form: "serviceForm"), (Url: root + "/edit", Title: "Edit Service", Form: "serviceForm"),
(Url: root + "/templates/create", Title: "Create Email Template", Form: "templateForm"), (Url: root + "/templates/create", Title: "Create Template", Form: "templateForm"),
(Url: root + "/templates/" + definition.Id, Title: "Edit Email Template", Form: "templateForm") (Url: root + "/templates/" + definition.Id, Title: "Template: Layout", Form: "templateForm")
}; };
foreach (var editor in editors) foreach (var editor in editors)
AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form); AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form);
@@ -32,7 +32,7 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
fields["VariablesJson"] = "{invalid"; fields["VariablesJson"] = "{invalid";
var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync(); var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
Assert.Contains("Invalid variables JSON", errorHtml); Assert.Contains("Invalid variables JSON", errorHtml);
AssertEditorLayout(errorHtml, "Create Email Template", "templateForm"); AssertEditorLayout(errorHtml, "Create Template", "templateForm");
Assert.Contains("<dl class=\"template-service-context\">", errorHtml); Assert.Contains("<dl class=\"template-service-context\">", errorHtml);
Assert.Contains("<dt>Service</dt>", errorHtml); Assert.Contains("<dt>Service</dt>", errorHtml);
Assert.Contains("<dd>TestService</dd>", errorHtml); Assert.Contains("<dd>TestService</dd>", errorHtml);
@@ -15,7 +15,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete() public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete()
{ {
await using var context = await DefinitionApiTestContext.CreateAsync(fixture); 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); var created = await context.Client.PostAsJsonAsync("/api/v1/email-channels", request);
Assert.Equal(HttpStatusCode.Created, created.StatusCode); Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var id = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid(); 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("smtp.example.invalid", original.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32()); Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-user", original.GetProperty("settings").GetProperty("username").GetString()); 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 all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
var updated = Assert.Single(all.EnumerateArray()); var updated = Assert.Single(all.EnumerateArray());
Assert.Equal("updated.example.invalid", updated.GetProperty("settings").GetProperty("host").GetString()); 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.False(updated.GetProperty("settings").GetProperty("useSsl").GetBoolean());
Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString()); Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString());
Assert.Equal("Test sender", updated.GetProperty("settings").GetProperty("fromName").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.Single(filtered.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync($"/api/v1/email-channels/{id}")).StatusCode); 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); 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] [Fact]
public async Task GetAllIncludesChannelsWhoseServiceNameHasNoServiceRecord() public async Task ForeignKeyRejectsChannelsWithoutAnExistingService()
{ {
await using var context = await DefinitionApiTestContext.CreateAsync(fixture); await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope(); using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>(); await Assert.ThrowsAsync<Microsoft.EntityFrameworkCore.DbUpdateException>(() =>
foreach (var serviceName in new[] { "TestService", "OrphanChannelService" }) scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
await channels.AddAsync(new EmailChannel {
{ ServiceId = Guid.NewGuid(), EmailChannelType = EmailChannelType.Smtp,
ServiceName = serviceName, EmailChannelType = EmailChannelType.Smtp, Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
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()));
} }
[Theory] [Theory]
@@ -64,18 +58,22 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
[InlineData("invalidPort")] [InlineData("invalidPort")]
[InlineData("blankHost")] [InlineData("blankHost")]
[InlineData("unsupportedType")] [InlineData("unsupportedType")]
[InlineData("blankService")] [InlineData("missingService")]
[InlineData("emptyService")]
[InlineData("nameOnly")]
public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario) public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario)
{ {
await using var context = await DefinitionApiTestContext.CreateAsync(fixture); 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) switch (scenario)
{ {
case "nullSettings": body["settings"] = null; break; case "nullSettings": body["settings"] = null; break;
case "invalidPort": body["settings"]!["port"] = 0; break; case "invalidPort": body["settings"]!["port"] = 0; break;
case "blankHost": body["settings"]!["host"] = " "; break; case "blankHost": body["settings"]!["host"] = " "; break;
case "unsupportedType": body["channelType"] = 99; 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); var response = await context.Client.PostAsJsonAsync("/api/v1/email-channels", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); 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()); 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" }, 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 dailyLimit = 100, monthlyLimit = 1000, warnThresholdPercent = 90, isActive = true
}; };
@@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceLinkTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task RenameUpdatesAllReadPathsAndResolvesChannelsByCurrentName()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using (var scope = context.App.Services.CreateScope())
{
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Invemory-Renamed";
await services.SaveAsync(service, false);
}
var read = (await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/email-channels/{channel.Id}")).GetProperty("data");
Assert.Equal(context.ServiceId, read.GetProperty("serviceId").GetGuid());
Assert.Equal("Invemory-Renamed", read.GetProperty("serviceName").GetString());
var current = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=Invemory-Renamed");
Assert.Single(current.GetProperty("data").EnumerateArray());
var previous = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=TestService");
Assert.Empty(previous.GetProperty("data").EnumerateArray());
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync("/admin/channels"));
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync($"/admin/channels/{channel.Id}"));
}
[Fact]
public async Task EditorAllowsReassignmentWithoutReplacingPasswordOrUsage()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using var scope = context.App.Services.CreateScope();
var destination = new NotificationServiceInfo { Name = "Invemory-Other" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(destination, true);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var html = await client.GetStringAsync($"/admin/channels/{channel.Id}");
Assert.Contains("<select", html);
Assert.Contains("name=\"ServiceId\"", html);
Assert.Contains("Invemory-Other", html);
Assert.DoesNotContain("name=\"ServiceName\"", html);
var fields = TemplateEditorTestFields.Read(html);
fields["ServiceId"] = destination.Id.ToString();
fields["Password"] = "";
fields["UseSsl"] = "true";
fields["IsActive"] = "true";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var readScope = context.App.Services.CreateScope();
var channels = readScope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
var saved = (await channels.GetByIdAsync(channel.Id))!;
Assert.Equal(destination.Id, saved.ServiceId);
Assert.Equal("Invemory-Other", saved.ServiceName);
Assert.Equal("test-only-password", Assert.IsType<SmtpChannelSettings>(saved.Settings).Password);
Assert.Equal(25, saved.DailyLimit);
Assert.Equal(250, saved.MonthlyLimit);
Assert.Equal(2, saved.Priority);
var summary = Assert.Single(await channels.GetAllWithUsageSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow)));
Assert.Equal(1, summary.DailySent);
Assert.Empty(await channels.GetByServiceAsync(context.ServiceId));
Assert.Single(await channels.GetByServiceAsync(destination.Id));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UpdateRejectsMissingServiceWithoutChangingChannel(bool empty)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
var response = await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{channel.Id}", new
{
serviceId = empty ? Guid.Empty : Guid.NewGuid(), priority = 9,
settings = (SmtpChannelSettings)channel.Settings, dailyLimit = 1, monthlyLimit = 2, warnThresholdPercent = 80, isActive = false
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Select an existing service.", result.GetProperty("error").GetProperty("message").GetString());
using var scope = context.App.Services.CreateScope();
var saved = (await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetByIdAsync(channel.Id))!;
Assert.Equal(context.ServiceId, saved.ServiceId);
Assert.Equal(2, saved.Priority);
Assert.Equal(25, saved.DailyLimit);
}
private static async Task<EmailChannel> SeedAsync(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceId = context.ServiceId, Priority = 2, EmailChannelType = EmailChannelType.Smtp,
DailyLimit = 25, MonthlyLimit = 250, IsActive = true,
Settings = new SmtpChannelSettings
{
Host = "smtp.invalid", Port = 587, Username = "test-user", Password = "test-only-password",
UseSsl = true, FromEmail = "sender@example.invalid", FromName = "Invemory Test"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<IEmailChannelUsageRepository>()
.IncrementUsageAsync(channel.Id, DateOnly.FromDateTime(DateTime.UtcNow));
return channel;
}
}
@@ -0,0 +1,91 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
private const string PreviousMigration = "20261023223753_AddPushDeliveries";
[Theory]
[InlineData("StoreMate-Prod", "Invemory-Prod")]
[InlineData("Invemory.Staging.Sandbox", "Invemory.Staging.Sandbox")]
public async Task BackfillPreservesChannelSettingsLimitsAndUsage(string legacyName, string currentName)
{
var connection = await PrepareAsync(legacyName, currentName);
await using (var db = TemplateDatabaseFixture.CreateContext(connection))
await db.Database.MigrateAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
var channel = Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
var service = await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync(currentName);
Assert.Equal(service!.Id, channel.ServiceId);
Assert.Equal(currentName, channel.ServiceName);
Assert.Equal(7, channel.Priority);
Assert.Equal(25, channel.DailyLimit);
Assert.Equal(250, channel.MonthlyLimit);
Assert.Equal(80, channel.WarnThresholdPercent);
Assert.False(channel.IsActive);
Assert.Equal("migration-test-value", Assert.IsType<SmtpChannelSettings>(channel.Settings).Password);
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(1L, await ScalarAsync(connection, "SELECT count(*) FROM email_channel_usage u JOIN email_channels c ON c.id = u.provider_id"));
await using var rollback = TemplateDatabaseFixture.CreateContext(connection);
await rollback.GetService<IMigrator>().MigrateAsync(PreviousMigration);
Assert.Equal(currentName, await ScalarAsync(connection, "SELECT service_name FROM email_channels"));
}
[Theory]
[InlineData("UnmappedService", false)]
[InlineData("StoreMate-Prod", true)]
public async Task MissingOrAmbiguousMappingAbortsWithoutChangingChannelData(string legacyName, bool ambiguous)
{
var connection = await PrepareAsync(legacyName, "Invemory-Prod");
if (ambiguous)
await TemplateDatabaseFixture.ExecuteAsync(connection,
"INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), 'StoreMate-Prod', gen_random_uuid(), now())");
var before = await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var error = await Assert.ThrowsAsync<PostgresException>(() => db.Database.MigrateAsync());
Assert.Contains("exactly one existing service", error.MessageText);
Assert.Equal(before, await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c"));
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(0L, await ScalarAsync(connection,
"SELECT count(*) FROM information_schema.columns WHERE table_name = 'email_channels' AND column_name = 'service_id'"));
}
private async Task<string> PrepareAsync(string legacyName, string currentName)
{
var connectionString = await fixture.CreateDatabaseAsync(legacy: true);
await using (var db = TemplateDatabaseFixture.CreateContext(connectionString))
await db.GetService<IMigrator>().MigrateAsync(PreviousMigration);
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand("""
INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), @current, gen_random_uuid(), now());
INSERT INTO email_channels(id, service_name, priority, provider_type, settings, daily_limit, monthly_limit,
warn_threshold_percent, is_active, created)
VALUES (@channel, @legacy, 7, 1, '{"Host":"smtp.invalid","Password":"migration-test-value"}', 25, 250, 80, false, now());
INSERT INTO email_channel_usage(id, provider_id, date, sent_count, created)
VALUES (gen_random_uuid(), @channel, CURRENT_DATE, 12, now());
""", connection);
command.Parameters.AddWithValue("current", currentName);
command.Parameters.AddWithValue("legacy", legacyName);
command.Parameters.AddWithValue("channel", Guid.NewGuid());
await command.ExecuteNonQueryAsync();
return connectionString;
}
private static async Task<object?> ScalarAsync(string connectionString, string sql)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand(sql, connection);
return await command.ExecuteScalarAsync();
}
}
@@ -22,7 +22,7 @@ public sealed class InterimEmailTemplateMigrationTests(TemplateDatabaseFixture f
Assert.Equal(firstServices, await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s")); 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); await using var db = TemplateDatabaseFixture.CreateContext(connection);
var history = (await db.Database.GetAppliedMigrationsAsync()).ToList(); 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); Assert.Contains(InterimEmailTemplateFixture.MigrationId, history);
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider(); using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope(); 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));
}
}

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