Compare commits

...

11 Commits

Author SHA1 Message Date
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 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
179 changed files with 8424 additions and 222 deletions
+1
View File
@@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<!-- Entity Framework Core -->
<PackageVersion Include="FirebaseAdmin" Version="3.7.0" />
<PackageVersion Include="HrynCo.DAL.Abstract" Version="1.0.11" />
<PackageVersion Include="HrynCo.DAL.EF" Version="1.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
@@ -2,6 +2,10 @@ namespace HrynCo.NotificationService.Contracts.Messages;
public record NotificationResultData
{
public Guid? PushRegistrationId { get; init; }
public Guid? PushBindingId { get; init; }
public string? PushTokenHash { get; init; }
public bool InvalidPushRegistration { get; init; }
public required string ServiceName { get; init; }
public required string RecipientEmail { get; init; }
public required string TemplateKey { get; init; }
@@ -0,0 +1,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 required string ServiceName { get; set; }
public required Guid ServiceId { get; set; }
public string ServiceName { get; set; } = string.Empty;
public int Priority { get; set; }
public EmailChannelType EmailChannelType { get; set; }
public required EmailChannelSettings Settings { get; set; }
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public interface IPushDeliveryRepository
{
Task<PushDeliveryOutcome> DeliverOnceAsync(Guid messageId, string serviceName,
Func<CancellationToken, Task<PushDeliveryOutcome>> deliver, CancellationToken cancellationToken);
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public sealed record PushDeliveryOutcome(string? Error, bool InvalidRegistration = false);
@@ -0,0 +1,44 @@
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Parse only service-account fields; never honor caller-supplied OAuth endpoints.
public sealed class FirebaseServiceAccountCredentials
{
public required string ProjectId { get; init; }
public required string ClientEmail { get; init; }
public required string PrivateKey { get; init; }
public static bool IsValidProjectId(string projectId) => Regex.IsMatch(projectId,
"^[a-z][a-z0-9-]{4,28}[a-z0-9]$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
public static FirebaseServiceAccountCredentials Parse(string json, string projectId)
{
if (!IsValidProjectId(projectId) || json.Length > 32768)
throw new FormatException("Invalid Firebase configuration.");
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (Read(root, "type") != "service_account" || Read(root, "project_id") != projectId)
throw new FormatException("The service account must belong to the selected Firebase project.");
var email = Read(root, "client_email");
if (!MailAddress.TryCreate(email, out var address) || address.Address != email)
throw new FormatException("Invalid service account email.");
var key = Read(root, "private_key");
using var rsa = RSA.Create();
rsa.ImportFromPem(key);
// Exporting private parameters also rejects a public-key-only PEM.
rsa.ExportParameters(true);
return new FirebaseServiceAccountCredentials { ProjectId = projectId, ClientEmail = email, PrivateKey = key };
}
private static string Read(JsonElement root, string name)
{
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var value)
|| value.ValueKind != JsonValueKind.String)
throw new FormatException("Missing service account fields.");
return value.GetString() ?? string.Empty;
}
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public interface IPushChannelRepository
{
Task<IReadOnlyList<PushChannel>> GetAllAsync(CancellationToken ct = default);
Task<PushChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<PushChannel?> GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default);
Task<string?> GetCredentialsAsync(Guid id, CancellationToken ct = default);
Task SaveAsync(PushChannel channel, string credentialsJson, bool isNew, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Safe administration/read model: credentials are fetched separately by server code.
public sealed class PushChannel : Entity
{
public required Guid ServiceId { get; set; }
public string ServiceName { get; set; } = string.Empty;
public required string FirebaseProjectId { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,6 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class PushChannelWriteException(string message, bool isConflict = true) : Exception(message)
{
public bool IsConflict { get; } = isConflict;
}
@@ -5,7 +5,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IEmailChannelRepository
{
Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default);
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(Guid serviceId, CancellationToken ct = default);
Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default);
Task AddAsync(EmailChannel channel, CancellationToken ct = default);
@@ -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.Property(x => x.Id).HasColumnName("id");
builder.Property(x => x.ServiceName)
.HasColumnName("service_name")
.IsRequired()
.HasMaxLength(100);
builder.HasIndex(x => new { x.ServiceName, x.Priority });
builder.Property(x => x.ServiceId).HasColumnName("service_id").IsRequired();
builder.HasOne(x => x.Service).WithMany().HasForeignKey(x => x.ServiceId)
.OnDelete(DeleteBehavior.Restrict);
builder.HasIndex(x => new { x.ServiceId, x.Priority });
builder.Property(x => x.Priority).HasColumnName("priority");
@@ -17,6 +17,9 @@ internal sealed class EmailTemplateDefinitionEntityConfiguration : IEntityTypeCo
builder.HasIndex(x => new { x.ServiceId, x.Key }).IsUnique();
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.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.Updated).HasColumnName("updated");
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
{
public required string ServiceName { get; set; }
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
public int Priority { get; set; }
public EmailChannelType EmailChannelType { get; set; }
@@ -4,6 +4,7 @@ namespace HrynCo.NotificationService.DAL.EF.Entities;
internal sealed class EmailTemplateDefinitionEntity : Entity
{
public TemplateEntity Template { get; set; } = null!;
public Guid ServiceId { get; set; }
public ServiceEntity Service { get; set; } = null!;
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")
.HasColumnName("priority");
b.Property<string>("ServiceName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("service_name");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<string>("SettingsJson")
.IsRequired()
@@ -74,7 +72,7 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.HasKey("Id");
b.HasIndex("ServiceName", "Priority");
b.HasIndex("ServiceId", "Priority");
b.ToTable("email_channels", (string)null);
});
@@ -206,6 +204,166 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.ToTable("email_templates", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<DateTimeOffset>("Created")
.HasColumnType("timestamp with time zone")
.HasColumnName("created");
b.Property<string>("CredentialsJson")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("credentials");
b.Property<string>("FirebaseProjectId")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("character varying(30)")
.HasColumnName("firebase_project_id");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<Guid>("ServiceId")
.HasColumnType("uuid")
.HasColumnName("service_id");
b.Property<DateTimeOffset?>("Updated")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated");
b.HasKey("Id");
b.HasIndex("ServiceId")
.IsUnique();
b.ToTable("push_channels", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
{
b.Property<string>("ServiceName")
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CompletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Error")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("InvalidRegistration")
.HasColumnType("boolean");
b.HasKey("ServiceName", "MessageId");
b.HasIndex("CompletedAt");
b.ToTable("PushDeliveries", (string)null);
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.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")
@@ -273,6 +431,47 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
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)
@@ -290,6 +489,13 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
.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");
@@ -318,6 +524,8 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("Service");
b.Navigation("Template");
b.Navigation("Variables");
});
@@ -342,6 +550,78 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
b.Navigation("ServiceLanguage");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
{
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
.WithMany()
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Service");
});
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.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")
@@ -353,6 +633,17 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
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");
@@ -363,10 +654,22 @@ namespace HrynCo.NotificationService.DAL.EF.Migrations
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
}
}
@@ -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<PushChannelEntity> PushChannels => Set<PushChannelEntity>();
internal DbSet<ServiceEntity> Services => Set<ServiceEntity>();
internal DbSet<EmailTemplateDefinitionEntity> TemplateDefinitions => Set<EmailTemplateDefinitionEntity>();
internal DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>();
@@ -8,9 +8,10 @@ internal sealed class NotificationDbContextFactory : IDesignTimeDbContextFactory
public NotificationDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<NotificationDbContext>()
.UseNpgsql("Host=localhost;Port=5432;Database=notification_service;Username=postgres;Password=postgres")
.UseNpgsql(Environment.GetEnvironmentVariable("App__ConnectionString")
?? "Host=localhost;Port=5432;Database=notification_service;Username=postgres;Password=postgres")
.Options;
return new NotificationDbContext(options);
}
}
}
@@ -15,14 +15,14 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default)
{
var entities = await EfRepository.Get().ToListAsync(ct);
var entities = await EfRepository.Get().AsNoTracking().Include(x => x.Service).ToListAsync(ct);
return entities.Select(MapToDomain).ToList();
}
public async Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default)
public async Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(Guid serviceId, CancellationToken ct = default)
{
var entities = await EfRepository.Get(x => x.ServiceName == serviceName)
var entities = await EfRepository.Get(x => x.ServiceId == serviceId).Include(x => x.Service)
.OrderBy(x => x.Priority)
.ToListAsync(ct);
@@ -32,8 +32,8 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(
DateOnly today, CancellationToken ct = default)
{
var rows = await EfRepository.Get()
.OrderBy(c => c.ServiceName)
var rows = await EfRepository.Get().AsNoTracking().Include(c => c.Service)
.OrderBy(c => c.Service.Name)
.ThenBy(c => c.Priority)
.Select(c => new
{
@@ -54,7 +54,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
public async Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
EmailChannelEntity? entity = await EfRepository.Get().AsNoTracking().SingleOrDefaultAsync(c => c.Id == id, ct);
EmailChannelEntity? entity = await EfRepository.Get().AsNoTracking().Include(c => c.Service).SingleOrDefaultAsync(c => c.Id == id, ct);
return entity is null ? null : MapToDomain(entity);
}
@@ -79,7 +79,8 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
return new EmailChannel
{
Id = e.Id,
ServiceName = e.ServiceName,
ServiceId = e.ServiceId,
ServiceName = e.Service.Name,
Priority = e.Priority,
EmailChannelType = e.EmailChannelType,
Settings = DeserializeSettings(e.EmailChannelType, e.SettingsJson),
@@ -97,7 +98,7 @@ internal sealed class EmailChannelRepository : NotificationBaseRepository<EmailC
return new EmailChannelEntity
{
Id = p.Id,
ServiceName = p.ServiceName,
ServiceId = p.ServiceId,
Priority = p.Priority,
EmailChannelType = p.EmailChannelType,
SettingsJson = JsonSerializer.Serialize(p.Settings, p.Settings.GetType()),
@@ -78,6 +78,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
if (entity is not null)
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.Template = await GetOrCreateTemplateAsync(service, patch.Key, ct);
db.TemplateDefinitions.Add(entity);
return entity;
}
@@ -125,6 +126,7 @@ internal sealed class EmailTemplateRepository(NotificationDbContext db, Template
if (definition.Revision != revision)
throw new EmailTemplateWriteException("The definition revision changed. Read it again before deleting.");
db.TemplateDefinitions.Remove(definition);
await RemoveEmptyTemplateAsync(serviceId, key, ct);
removed = true;
}, ct);
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);
var entity = new EmailTemplateDefinitionEntity
{
Template = await GetOrCreateTemplateAsync(service, key, ct),
Service = service, Key = key, Variables = MapVariables(variables), Created = DateTimeOffset.UtcNow
};
db.TemplateDefinitions.Add(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)
{
EmailTemplateValidation.ValidateIdentity(template.ServiceName, template.Key);
@@ -38,8 +38,7 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return;
if (service.Revision != revision)
throw new EmailTemplateWriteException("The service revision changed. Read it again before deleting.");
if (await db.TemplateDefinitions.AnyAsync(d => d.ServiceId == id, ct))
throw new EmailTemplateWriteException("Remove every template definition before deleting this service, including definitions without translations.");
await EnsureServiceIsEmptyAsync(id, ct);
db.ServiceLanguages.RemoveRange(service.Languages);
db.Services.Remove(service);
removed = true;
@@ -47,6 +46,14 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
return removed;
}
private async Task EnsureServiceIsEmptyAsync(Guid id, CancellationToken ct)
{
if (await db.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 () =>
{
Validate(model);
@@ -69,9 +76,11 @@ internal sealed class NotificationServiceRepository(NotificationDbContext db, Te
var language = service.Languages.SingleOrDefault(l => l.Id == languageId)
?? throw new EmailTemplateWriteException("Language no longer exists. Reload the service.");
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.");
db.Templates.RemoveRange(versions);
db.PushTemplates.RemoveRange(pushVersions);
// Save dependent removals first because language deletion is restricted by its FK.
await db.SaveChangesAsync(ct);
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();
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)
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.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()
.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);
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));
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<IPushTemplateDefinitionRepository, PushTemplateDefinitionRepository>();
services.AddScoped<IEmailTemplateRepository, EmailTemplateRepository>();
services.AddScoped<IEmailTemplateDefinitionRepository, EmailTemplateRepository>();
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))
throw new InvalidOperationException("Development seed service name is not configured.");
await SeedMailpitChannelAsync(serviceName, cancellationToken);
await SeedServiceAsync(serviceName, cancellationToken);
var serviceId = await SeedServiceAsync(serviceName, cancellationToken);
await SeedMailpitChannelAsync(serviceId, serviceName, cancellationToken);
foreach (EmailTemplate template in CreateTemplates(serviceName))
{
@@ -55,22 +55,23 @@ internal sealed class DevelopmentDataSeeder
}
}
private async Task SeedServiceAsync(string serviceName, CancellationToken ct)
private async Task<Guid> SeedServiceAsync(string serviceName, CancellationToken ct)
{
var services = await _services.GetAllAsync(ct);
var service = services.SingleOrDefault(s => s.Name == serviceName);
if (service?.Languages.Any(l => l.Code == DefaultLanguageCode) == true)
return;
return service.Id;
var isNew = service is null;
service ??= new NotificationServiceInfo { Name = serviceName };
service.Languages = [..service.Languages, new ServiceLanguageInfo { Code = DefaultLanguageCode }];
await _services.SaveAsync(service, isNew, ct);
return service.Id;
}
private async Task SeedMailpitChannelAsync(string serviceName, CancellationToken cancellationToken)
private async Task SeedMailpitChannelAsync(Guid serviceId, string serviceName, CancellationToken cancellationToken)
{
IReadOnlyList<EmailChannel> existingChannels = await _channels.GetByServiceAsync(
serviceName, cancellationToken);
serviceId, cancellationToken);
if (existingChannels.Count > 0)
{
@@ -82,7 +83,7 @@ internal sealed class DevelopmentDataSeeder
var channel = new EmailChannel
{
ServiceName = serviceName,
ServiceId = serviceId,
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
@@ -13,10 +13,16 @@ public sealed class DevelopmentDataSeederTests
private readonly IEmailTemplateRepository _templates = Substitute.For<IEmailTemplateRepository>();
private readonly INotificationServiceRepository _services = Substitute.For<INotificationServiceRepository>();
public DevelopmentDataSeederTests()
{
_services.SaveAsync(Arg.Any<NotificationServiceInfo>(), true, Arg.Any<CancellationToken>())
.Returns(call => { call.Arg<NotificationServiceInfo>().Id = Guid.NewGuid(); return Task.CompletedTask; });
}
[Fact]
public async Task SeedAsync_WhenConfigurationIsMissing_CreatesMailpitChannelAndTestTemplate()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
_channels.GetByServiceAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns(Array.Empty<EmailChannel>());
_templates.GetAsync("TestService", "TestEmail", "en", Arg.Any<CancellationToken>())
.Returns((EmailTemplate?)null);
@@ -39,10 +45,10 @@ public sealed class DevelopmentDataSeederTests
[Fact]
public async Task SeedAsync_WhenConfigurationExists_DoesNotDuplicateOrOverwriteIt()
{
_channels.GetByServiceAsync("TestService", Arg.Any<CancellationToken>())
_channels.GetByServiceAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns([new EmailChannel
{
ServiceName = "TestService",
ServiceId = Guid.NewGuid(),
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings()
}]);
@@ -75,7 +81,7 @@ public sealed class DevelopmentDataSeederTests
private static bool IsMailpitChannel(EmailChannel channel)
{
return channel.ServiceName == "TestService" &&
return channel.ServiceId != Guid.Empty &&
channel.IsActive &&
channel.Settings is SmtpChannelSettings smtp &&
smtp.Host == "mailpit" &&
@@ -12,7 +12,7 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
CreateContext(),
SendEmailConsumer.SupportedMessageType);
SupportedMessageType.SendEmailV1);
Assert.Null(error);
}
@@ -25,7 +25,7 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
SupportedMessageType.SendEmailV1);
Assert.Equal("AMQP MessageId is required.", error);
}
@@ -41,18 +41,33 @@ public sealed class SendEmailDeliveryValidatorTests
string? error = SendEmailDeliveryValidator.GetValidationError(
CreateMessage(),
context,
SendEmailConsumer.SupportedMessageType);
SupportedMessageType.SendEmailV1);
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error);
}
[Theory]
[InlineData("Notification.SendPush.v1")]
[InlineData("notification.sendemail.v1")]
[InlineData("SendEmailV1")]
[InlineData("0")]
[InlineData("")]
[InlineData(null)]
public void OtherOrMissingWireType_IsRejected(string? wireType)
{
string? error = SendEmailDeliveryValidator.GetValidationError(CreateMessage(),
CreateContext() with { MessageType = wireType }, SupportedMessageType.SendEmailV1);
Assert.Equal($"Unsupported AMQP message type '{wireType ?? "<missing>"}'.", error);
}
private static RabbitMqMessageContext CreateContext() => new()
{
QueueName = SendEmailConsumer.IncomingQueue,
Exchange = string.Empty,
RoutingKey = SendEmailConsumer.IncomingQueue,
MessageId = Guid.NewGuid().ToString("D"),
MessageType = SendEmailConsumer.SupportedMessageType,
MessageType = "Notification.SendEmail.v1",
CorrelationId = "correlation-id",
ContentType = "application/json"
};
@@ -23,12 +23,12 @@ public sealed class SendEmailServiceTests
Password = "not-a-real-secret",
UseSsl = true,
FromEmail = "notifications@example.com",
FromName = "StoreMate"
FromName = "Invemory"
};
var channel = new EmailChannel
{
Id = channelId,
ServiceName = "StoreMate-Prod",
ServiceId = Guid.NewGuid(),
Priority = 1,
EmailChannelType = EmailChannelType.Smtp,
Settings = settings,
@@ -36,7 +36,7 @@ public sealed class SendEmailServiceTests
};
var template = new EmailTemplate
{
ServiceName = "StoreMate-Prod",
ServiceName = "Invemory-Prod",
Key = "EmailVerification",
LanguageCode = "uk",
Subject = "Verify {{AppName}}",
@@ -46,17 +46,21 @@ public sealed class SendEmailServiceTests
};
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
channels.GetByServiceAsync(channel.ServiceId, Arg.Any<CancellationToken>())
.Returns([channel]);
var services = Substitute.For<INotificationServiceRepository>();
services.GetByNameAsync("Invemory-Prod", Arg.Any<CancellationToken>())
.Returns(new NotificationServiceInfo { Id = channel.ServiceId, Name = "Invemory-Prod" });
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
templates.GetAsync("Invemory-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
.Returns(template);
var renderer = new EmailTemplateRenderingService();
var smtp = new RecordingSmtpEmailSender();
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
var service = new SendEmailService(
channels,
services,
usage,
templates,
renderer,
@@ -71,12 +75,12 @@ public sealed class SendEmailServiceTests
},
Data = new SendEmailMessageData
{
ServiceName = "StoreMate-Prod",
ServiceName = "Invemory-Prod",
TemplateKey = "EmailVerification",
RecipientEmail = "owner@example.com",
RecipientName = "Owner",
LanguageCode = "uk",
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
Variables = new Dictionary<string, string> { ["AppName"] = "Invemory" }
}
};
@@ -84,7 +88,7 @@ public sealed class SendEmailServiceTests
Assert.Equal(1, smtp.SendCount);
Assert.Same(settings, smtp.Settings);
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
Assert.Equal("Verify Invemory", smtp.Email?.Subject);
Assert.Equal("owner@example.com", smtp.RecipientEmail);
Assert.Equal("Owner", smtp.RecipientName);
await usage.Received(1).IncrementUsageAsync(
@@ -0,0 +1,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;
public sealed record CreateEmailChannelCommand(
string ServiceName,
Guid ServiceId,
int Priority,
EmailChannelType ChannelType,
EmailChannelSettings Settings,
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
@@ -11,21 +10,27 @@ internal sealed class CreateEmailChannelHandler
: RequestHandler<CreateEmailChannelCommand, ServiceResult<Guid>>
{
private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public CreateEmailChannelHandler(
IContextualSerilogLogger<CreateEmailChannelCommand> logger,
IEmailChannelRepository channels)
IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger)
{
_channels = channels;
_services = services;
}
protected override async Task<ServiceResult<Guid>> DoOnHandle(
CreateEmailChannelCommand request, CancellationToken cancellationToken)
{
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<Guid>("Select an existing service.", ServiceErrorCode.InvalidRequest);
var channel = new EmailChannel
{
ServiceName = request.ServiceName,
ServiceId = request.ServiceId,
Priority = request.Priority,
EmailChannelType = request.ChannelType,
Settings = request.Settings,
@@ -10,19 +10,25 @@ internal sealed class GetEmailChannelsHandler
: RequestHandler<GetEmailChannelsQuery, ServiceResult<IReadOnlyList<EmailChannel>>>
{
private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public GetEmailChannelsHandler(
IContextualSerilogLogger<GetEmailChannelsQuery> logger,
IEmailChannelRepository channels)
IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger)
{
_channels = channels;
_services = services;
}
protected override async Task<ServiceResult<IReadOnlyList<EmailChannel>>> DoOnHandle(
GetEmailChannelsQuery request, CancellationToken cancellationToken)
{
var channels = await _channels.GetByServiceAsync(request.ServiceName, cancellationToken);
var service = await _services.GetByNameAsync(request.ServiceName, cancellationToken);
if (service is null)
return Success<IReadOnlyList<EmailChannel>>([]);
var channels = await _channels.GetByServiceAsync(service.Id, cancellationToken);
return Success(channels);
}
}
@@ -7,6 +7,7 @@ namespace HrynCo.NotificationService.Services.EmailChannels.Update;
public sealed record UpdateEmailChannelCommand(
Guid Id,
Guid ServiceId,
int Priority,
EmailChannelSettings Settings,
int? DailyLimit,
@@ -1,4 +1,3 @@
using HrynCo.DAL.Abstract;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
@@ -10,13 +9,16 @@ internal sealed class UpdateEmailChannelHandler
: RequestHandler<UpdateEmailChannelCommand, ServiceResult<Unit>>
{
private readonly IEmailChannelRepository _channels;
private readonly INotificationServiceRepository _services;
public UpdateEmailChannelHandler(
IContextualSerilogLogger<UpdateEmailChannelCommand> logger,
IEmailChannelRepository channels)
IEmailChannelRepository channels,
INotificationServiceRepository services)
: base(logger)
{
_channels = channels;
_services = services;
}
protected override async Task<ServiceResult<Unit>> DoOnHandle(
@@ -27,6 +29,10 @@ internal sealed class UpdateEmailChannelHandler
if (channel is null)
return Failure<Unit>("Email channel not found.", ServiceErrorCode.NotFound);
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
return Failure<Unit>("Select an existing service.", ServiceErrorCode.InvalidRequest);
channel.ServiceId = request.ServiceId;
channel.Priority = request.Priority;
channel.Settings = request.Settings;
channel.DailyLimit = request.DailyLimit;
@@ -0,0 +1,14 @@
using HrynCo.NotificationService.Services.Core;
using MediatR;
namespace HrynCo.NotificationService.Services.PushChannels;
// A class deliberately avoids record-generated ToString exposing the submitted secret.
public sealed class SavePushChannelCommand : IRequest<ServiceResult<Guid>>
{
public Guid Id { get; init; }
public Guid ServiceId { get; init; }
public required string FirebaseProjectId { get; init; }
public string? CredentialsJson { get; init; }
public bool IsActive { get; init; }
}
@@ -0,0 +1,68 @@
using System.Security.Cryptography;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.Services.Core;
using HrynCo.NotificationService.Services.Logging;
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
namespace HrynCo.NotificationService.Services.PushChannels;
internal sealed class SavePushChannelHandler(
IContextualSerilogLogger<SavePushChannelCommand> logger, IPushChannelRepository channels)
: RequestHandler<SavePushChannelCommand, ServiceResult<Guid>>(logger)
{
protected override async Task<ServiceResult<Guid>> DoOnHandle(SavePushChannelCommand request, CancellationToken cancellationToken)
{
var existing = await channels.GetByIdAsync(request.Id, cancellationToken);
if (request.Id != Guid.Empty && existing is null)
return Failure<Guid>("Push channel not found.", ServiceErrorCode.NotFound);
var projectId = request.FirebaseProjectId.Trim();
var credentials = await ResolveCredentialsAsync(request, existing, projectId, cancellationToken);
if (!ValidCredentials(credentials, projectId))
return Failure<Guid>("Provide a valid Firebase service account JSON for this project. Changing project requires new credentials.", ServiceErrorCode.InvalidRequest);
var channel = new PushChannel
{
Id = existing?.Id ?? Guid.NewGuid(), ServiceId = request.ServiceId,
FirebaseProjectId = projectId, IsActive = request.IsActive
};
return await SaveAsync(channel, credentials!, existing is null, cancellationToken);
}
private async Task<string?> ResolveCredentialsAsync(SavePushChannelCommand request, PushChannel? existing,
string projectId, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(request.CredentialsJson))
return request.CredentialsJson;
if (existing is null || existing.FirebaseProjectId != projectId)
return null;
return await channels.GetCredentialsAsync(existing.Id, ct);
}
private static bool ValidCredentials(string? json, string projectId)
{
if (string.IsNullOrWhiteSpace(json)) return false;
try
{
FirebaseServiceAccountCredentials.Parse(json, projectId);
return true;
}
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
{
// Neither parser exceptions nor submitted JSON are sent to logs or clients.
return false;
}
}
private async Task<ServiceResult<Guid>> SaveAsync(PushChannel channel, string credentials, bool isNew, CancellationToken ct)
{
try
{
await channels.SaveAsync(channel, credentials, isNew, ct);
return Success(channel.Id);
}
catch (PushChannelWriteException error)
{
return Failure<Guid>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
}
}
}
@@ -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 blocked = await client.GetStringAsync(url);
Assert.Contains("1 template definition(s)", blocked);
Assert.Contains("Email channels are retained", blocked);
Assert.Contains("Channels are never deleted automatically", blocked);
var fields = TemplateEditorTestFields.Read(blocked);
fields["ConfirmDelete"] = "true";
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
@@ -102,7 +102,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var root = $"/admin/services/{context.ServiceId}";
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(root + "/templates/create", 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("Templates are unavailable", 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) =>
@@ -43,7 +43,7 @@ public sealed class AdminNavigationTests(TemplateDatabaseFixture fixture) : ICla
using var scope = context.App.Services.CreateScope();
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
@@ -14,7 +14,7 @@ public sealed class AdminSmtpEditorViewTests(TemplateDatabaseFixture fixture) :
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceName = "TestService",
ServiceId = context.ServiceId,
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
@@ -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);
}
}
@@ -89,7 +89,7 @@ public sealed class AdminSmtpTestControllerTests
recorder.Channel = new EmailChannel
{
Id = ChannelId,
ServiceName = "TestService",
ServiceId = Guid.NewGuid(),
EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings
{
@@ -97,7 +97,7 @@ public sealed class AdminSmtpTestControllerTests
Username = "test-user", Password = "saved-test-secret", UseSsl = true
}
};
return (new AdminChannelsController(mediator), recorder);
return (new AdminChannelsController(mediator, null!), recorder);
}
private static TestSmtpRequest Request() => new(
@@ -8,7 +8,7 @@ public sealed class AdminTemplatesIndexViewTests
string view = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "TestViews", "Index.cshtml"));
Assert.Contains("asp-route-serviceId=\"@serviceId\"", 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);
}
}
@@ -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: root + "/edit", Title: "Edit Service", Form: "serviceForm"),
(Url: root + "/templates/create", Title: "Create Email Template", Form: "templateForm"),
(Url: root + "/templates/" + definition.Id, Title: "Edit Email Template", Form: "templateForm")
(Url: root + "/templates/create", Title: "Create Template", Form: "templateForm"),
(Url: root + "/templates/" + definition.Id, Title: "Template: Layout", Form: "templateForm")
};
foreach (var editor in editors)
AssertEditorLayout(await client.GetStringAsync(editor.Url), editor.Title, editor.Form);
@@ -32,7 +32,7 @@ public sealed class EditorLayoutTests(TemplateDatabaseFixture fixture) : IClassF
fields["VariablesJson"] = "{invalid";
var errorHtml = await (await client.PostAsync(root + "/templates/save", new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync();
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("<dt>Service</dt>", errorHtml);
Assert.Contains("<dd>TestService</dd>", errorHtml);
@@ -15,7 +15,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
public async Task SmtpSettingsRoundTripThroughCreateReadUpdateListAndDelete()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var request = Content("smtp.example.invalid", 2525, "test-only-user");
var request = Content(context.ServiceId, "smtp.example.invalid", 2525, "test-only-user");
var created = await context.Client.PostAsJsonAsync("/api/v1/email-channels", request);
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
var id = (await created.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("data").GetGuid();
@@ -23,7 +23,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.Equal("smtp.example.invalid", original.GetProperty("settings").GetProperty("host").GetString());
Assert.Equal(2525, original.GetProperty("settings").GetProperty("port").GetInt32());
Assert.Equal("test-only-user", original.GetProperty("settings").GetProperty("username").GetString());
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content("updated.example.invalid", 2526, "updated-user"))).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{id}", Content(context.ServiceId, "updated.example.invalid", 2526, "updated-user"))).StatusCode);
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
var updated = Assert.Single(all.EnumerateArray());
Assert.Equal("updated.example.invalid", updated.GetProperty("settings").GetProperty("host").GetString());
@@ -33,7 +33,7 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.False(updated.GetProperty("settings").GetProperty("useSsl").GetBoolean());
Assert.Equal("sender@example.invalid", updated.GetProperty("settings").GetProperty("fromEmail").GetString());
Assert.Equal("Test sender", updated.GetProperty("settings").GetProperty("fromName").GetString());
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=IndependentChannelService");
var filtered = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=TestService");
Assert.Single(filtered.GetProperty("data").EnumerateArray());
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync($"/api/v1/email-channels/{id}")).StatusCode);
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync($"/api/v1/email-channels/{id}")).StatusCode);
@@ -41,22 +41,16 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
}
[Fact]
public async Task GetAllIncludesChannelsWhoseServiceNameHasNoServiceRecord()
public async Task ForeignKeyRejectsChannelsWithoutAnExistingService()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
foreach (var serviceName in new[] { "TestService", "OrphanChannelService" })
await channels.AddAsync(new EmailChannel
{
ServiceName = serviceName, EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
});
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
var all = (await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data");
Assert.Equal(2, all.GetArrayLength());
Assert.Contains(all.EnumerateArray(), c => c.GetProperty("serviceName").GetString() == "OrphanChannelService");
Assert.All(all.EnumerateArray(), c => Assert.NotEqual(Guid.Empty, c.GetProperty("id").GetGuid()));
await Assert.ThrowsAsync<Microsoft.EntityFrameworkCore.DbUpdateException>(() =>
scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
{
ServiceId = Guid.NewGuid(), EmailChannelType = EmailChannelType.Smtp,
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
}));
}
[Theory]
@@ -64,18 +58,22 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
[InlineData("invalidPort")]
[InlineData("blankHost")]
[InlineData("unsupportedType")]
[InlineData("blankService")]
[InlineData("missingService")]
[InlineData("emptyService")]
[InlineData("nameOnly")]
public async Task InvalidSmtpCreateReturnsValidationEnvelopeWithoutPersisting(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var body = JsonSerializer.SerializeToNode(Content("smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
var body = JsonSerializer.SerializeToNode(Content(context.ServiceId, "smtp.example.invalid", 2525, "test-user"), new JsonSerializerOptions(JsonSerializerDefaults.Web))!.AsObject();
switch (scenario)
{
case "nullSettings": body["settings"] = null; break;
case "invalidPort": body["settings"]!["port"] = 0; break;
case "blankHost": body["settings"]!["host"] = " "; break;
case "unsupportedType": body["channelType"] = 99; break;
case "blankService": body["serviceName"] = " "; break;
case "missingService": body["serviceId"] = Guid.NewGuid(); break;
case "emptyService": body["serviceId"] = Guid.Empty; break;
case "nameOnly": body.Remove("serviceId"); body["serviceName"] = "TestService"; break;
}
var response = await context.Client.PostAsJsonAsync("/api/v1/email-channels", body);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
@@ -83,9 +81,9 @@ public sealed class EmailChannelApiTests(TemplateDatabaseFixture fixture) : ICla
Assert.Empty((await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels/all")).GetProperty("data").EnumerateArray());
}
private static object Content(string host, int port, string username) => new
private static object Content(Guid serviceId, string host, int port, string username) => new
{
serviceName = "IndependentChannelService", priority = 1, channelType = 1,
serviceId, priority = 1, channelType = 1,
settings = new { host, port, username, password = "test-only-value", useSsl = false, fromEmail = "sender@example.invalid", fromName = "Test sender" },
dailyLimit = 100, monthlyLimit = 1000, warnThresholdPercent = 90, isActive = true
};
@@ -0,0 +1,112 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceLinkTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task RenameUpdatesAllReadPathsAndResolvesChannelsByCurrentName()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using (var scope = context.App.Services.CreateScope())
{
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Invemory-Renamed";
await services.SaveAsync(service, false);
}
var read = (await context.Client.GetFromJsonAsync<JsonElement>($"/api/v1/email-channels/{channel.Id}")).GetProperty("data");
Assert.Equal(context.ServiceId, read.GetProperty("serviceId").GetGuid());
Assert.Equal("Invemory-Renamed", read.GetProperty("serviceName").GetString());
var current = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=Invemory-Renamed");
Assert.Single(current.GetProperty("data").EnumerateArray());
var previous = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/email-channels?serviceName=TestService");
Assert.Empty(previous.GetProperty("data").EnumerateArray());
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync("/admin/channels"));
Assert.Contains("Invemory-Renamed", await context.Client.GetStringAsync($"/admin/channels/{channel.Id}"));
}
[Fact]
public async Task EditorAllowsReassignmentWithoutReplacingPasswordOrUsage()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
using var scope = context.App.Services.CreateScope();
var destination = new NotificationServiceInfo { Name = "Invemory-Other" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(destination, true);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var html = await client.GetStringAsync($"/admin/channels/{channel.Id}");
Assert.Contains("<select", html);
Assert.Contains("name=\"ServiceId\"", html);
Assert.Contains("Invemory-Other", html);
Assert.DoesNotContain("name=\"ServiceName\"", html);
var fields = TemplateEditorTestFields.Read(html);
fields["ServiceId"] = destination.Id.ToString();
fields["Password"] = "";
fields["UseSsl"] = "true";
fields["IsActive"] = "true";
var response = await client.PostAsync("/admin/channels/save", new FormUrlEncodedContent(fields));
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var readScope = context.App.Services.CreateScope();
var channels = readScope.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
var saved = (await channels.GetByIdAsync(channel.Id))!;
Assert.Equal(destination.Id, saved.ServiceId);
Assert.Equal("Invemory-Other", saved.ServiceName);
Assert.Equal("test-only-password", Assert.IsType<SmtpChannelSettings>(saved.Settings).Password);
Assert.Equal(25, saved.DailyLimit);
Assert.Equal(250, saved.MonthlyLimit);
Assert.Equal(2, saved.Priority);
var summary = Assert.Single(await channels.GetAllWithUsageSummaryAsync(DateOnly.FromDateTime(DateTime.UtcNow)));
Assert.Equal(1, summary.DailySent);
Assert.Empty(await channels.GetByServiceAsync(context.ServiceId));
Assert.Single(await channels.GetByServiceAsync(destination.Id));
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task UpdateRejectsMissingServiceWithoutChangingChannel(bool empty)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
var channel = await SeedAsync(context);
var response = await context.Client.PutAsJsonAsync($"/api/v1/email-channels/{channel.Id}", new
{
serviceId = empty ? Guid.Empty : Guid.NewGuid(), priority = 9,
settings = (SmtpChannelSettings)channel.Settings, dailyLimit = 1, monthlyLimit = 2, warnThresholdPercent = 80, isActive = false
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Select an existing service.", result.GetProperty("error").GetProperty("message").GetString());
using var scope = context.App.Services.CreateScope();
var saved = (await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetByIdAsync(channel.Id))!;
Assert.Equal(context.ServiceId, saved.ServiceId);
Assert.Equal(2, saved.Priority);
Assert.Equal(25, saved.DailyLimit);
}
private static async Task<EmailChannel> SeedAsync(DefinitionApiTestContext context)
{
using var scope = context.App.Services.CreateScope();
var channel = new EmailChannel
{
ServiceId = context.ServiceId, Priority = 2, EmailChannelType = EmailChannelType.Smtp,
DailyLimit = 25, MonthlyLimit = 250, IsActive = true,
Settings = new SmtpChannelSettings
{
Host = "smtp.invalid", Port = 587, Username = "test-user", Password = "test-only-password",
UseSsl = true, FromEmail = "sender@example.invalid", FromName = "Invemory Test"
}
};
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(channel);
await scope.ServiceProvider.GetRequiredService<IEmailChannelUsageRepository>()
.IncrementUsageAsync(channel.Id, DateOnly.FromDateTime(DateTime.UtcNow));
return channel;
}
}
@@ -0,0 +1,91 @@
using HrynCo.NotificationService.DAL.Abstract.Providers;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.EF;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class EmailChannelServiceMigrationTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
private const string PreviousMigration = "20261023223753_AddPushDeliveries";
[Theory]
[InlineData("StoreMate-Prod", "Invemory-Prod")]
[InlineData("Invemory.Staging.Sandbox", "Invemory.Staging.Sandbox")]
public async Task BackfillPreservesChannelSettingsLimitsAndUsage(string legacyName, string currentName)
{
var connection = await PrepareAsync(legacyName, currentName);
await using (var db = TemplateDatabaseFixture.CreateContext(connection))
await db.Database.MigrateAsync();
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
var channel = Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
var service = await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync(currentName);
Assert.Equal(service!.Id, channel.ServiceId);
Assert.Equal(currentName, channel.ServiceName);
Assert.Equal(7, channel.Priority);
Assert.Equal(25, channel.DailyLimit);
Assert.Equal(250, channel.MonthlyLimit);
Assert.Equal(80, channel.WarnThresholdPercent);
Assert.False(channel.IsActive);
Assert.Equal("migration-test-value", Assert.IsType<SmtpChannelSettings>(channel.Settings).Password);
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(1L, await ScalarAsync(connection, "SELECT count(*) FROM email_channel_usage u JOIN email_channels c ON c.id = u.provider_id"));
await using var rollback = TemplateDatabaseFixture.CreateContext(connection);
await rollback.GetService<IMigrator>().MigrateAsync(PreviousMigration);
Assert.Equal(currentName, await ScalarAsync(connection, "SELECT service_name FROM email_channels"));
}
[Theory]
[InlineData("UnmappedService", false)]
[InlineData("StoreMate-Prod", true)]
public async Task MissingOrAmbiguousMappingAbortsWithoutChangingChannelData(string legacyName, bool ambiguous)
{
var connection = await PrepareAsync(legacyName, "Invemory-Prod");
if (ambiguous)
await TemplateDatabaseFixture.ExecuteAsync(connection,
"INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), 'StoreMate-Prod', gen_random_uuid(), now())");
var before = await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c");
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var error = await Assert.ThrowsAsync<PostgresException>(() => db.Database.MigrateAsync());
Assert.Contains("exactly one existing service", error.MessageText);
Assert.Equal(before, await ScalarAsync(connection, "SELECT row_to_json(c)::text FROM email_channels c"));
Assert.Equal(12, await ScalarAsync(connection, "SELECT sent_count FROM email_channel_usage"));
Assert.Equal(0L, await ScalarAsync(connection,
"SELECT count(*) FROM information_schema.columns WHERE table_name = 'email_channels' AND column_name = 'service_id'"));
}
private async Task<string> PrepareAsync(string legacyName, string currentName)
{
var connectionString = await fixture.CreateDatabaseAsync(legacy: true);
await using (var db = TemplateDatabaseFixture.CreateContext(connectionString))
await db.GetService<IMigrator>().MigrateAsync(PreviousMigration);
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand("""
INSERT INTO services(id, name, revision, created) VALUES (gen_random_uuid(), @current, gen_random_uuid(), now());
INSERT INTO email_channels(id, service_name, priority, provider_type, settings, daily_limit, monthly_limit,
warn_threshold_percent, is_active, created)
VALUES (@channel, @legacy, 7, 1, '{"Host":"smtp.invalid","Password":"migration-test-value"}', 25, 250, 80, false, now());
INSERT INTO email_channel_usage(id, provider_id, date, sent_count, created)
VALUES (gen_random_uuid(), @channel, CURRENT_DATE, 12, now());
""", connection);
command.Parameters.AddWithValue("current", currentName);
command.Parameters.AddWithValue("legacy", legacyName);
command.Parameters.AddWithValue("channel", Guid.NewGuid());
await command.ExecuteNonQueryAsync();
return connectionString;
}
private static async Task<object?> ScalarAsync(string connectionString, string sql)
{
await using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new NpgsqlCommand(sql, connection);
return await command.ExecuteScalarAsync();
}
}
@@ -22,7 +22,7 @@ public sealed class InterimEmailTemplateMigrationTests(TemplateDatabaseFixture f
Assert.Equal(firstServices, await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s"));
await using var db = TemplateDatabaseFixture.CreateContext(connection);
var history = (await db.Database.GetAppliedMigrationsAsync()).ToList();
Assert.Equal(4, history.Count);
Assert.Equal(db.Database.GetMigrations().Append(InterimEmailTemplateFixture.MigrationId).Order(), history.Order());
Assert.Contains(InterimEmailTemplateFixture.MigrationId, history);
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
using var scope = provider.CreateScope();
@@ -0,0 +1,153 @@
using System.Net;
using System.Net.Http.Json;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
using Microsoft.Extensions.DependencyInjection;
namespace HrynCo.NotificationService.Web.IntegrationTests;
public sealed class PushChannelAdminTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
{
[Fact]
public async Task TestsRequireAntiforgeryAndRejectMissingCredentialsWithoutSaving()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient();
var html = await client.GetStringAsync("/admin/push-channels/create");
Assert.Contains("Check Firebase authorization", html);
Assert.Contains("Recipient token", html);
Assert.Contains("test-authorization", html);
var fields = TemplateEditorTestFields.Read(html);
foreach (var action in new[] { "test-authorization", "test-send" })
{
var body = new { firebaseProjectId = "test-project", recipientToken = "test-token" };
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/admin/push-channels/" + action, body)).StatusCode);
using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/" + action);
request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]);
request.Content = JsonContent.Create(body);
var response = await client.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var result = await response.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>();
Assert.False(result.GetProperty("success").GetBoolean());
Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync());
}
using var scope = context.App.Services.CreateScope();
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync());
}
[Fact]
public async Task CreateAndEditKeepCredentialsOnServerAndAllowServiceReassignment()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
var response = await SaveNewAsync(client, context.ServiceId, credentials);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
var other = new NotificationServiceInfo { Name = "Invemory-Push-Test" };
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
var html = await client.GetStringAsync($"/admin/push-channels/{channel.Id}");
Assert.DoesNotContain("PRIVATE KEY", html);
Assert.DoesNotContain("test@example.invalid", html);
Assert.Contains("Credentials are saved", html);
var fields = TemplateEditorTestFields.Read(html);
fields["ServiceId"] = other.Id.ToString();
fields["CredentialsJson"] = "";
fields["IsActive"] = "true";
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields))).StatusCode);
var saved = (await channels.GetByIdAsync(channel.Id))!;
Assert.Equal(other.Id, saved.ServiceId);
Assert.Equal("Invemory-Push-Test", saved.ServiceName);
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(credentials),
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
Assert.NotNull(await channels.GetActiveForServiceAsync(other.Id));
}
[Theory]
[InlineData("duplicate")]
[InlineData("missingService")]
[InlineData("emptyService")]
[InlineData("invalidJson")]
[InlineData("missingCredentials")]
public async Task InvalidCreateDoesNotExposeSubmittedSecretOrPersistAChannel(string scenario)
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
if (scenario == "duplicate")
Assert.Equal(HttpStatusCode.Redirect, (await SaveNewAsync(client, context.ServiceId, credentials)).StatusCode);
var serviceId = scenario switch { "missingService" => Guid.NewGuid(), "emptyService" => Guid.Empty, _ => context.ServiceId };
var secret = scenario switch { "invalidJson" => "invalid-secret-must-not-render", "missingCredentials" => "", _ => credentials };
var response = await SaveNewAsync(client, serviceId, secret);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.DoesNotContain("PRIVATE KEY", html);
Assert.DoesNotContain("invalid-secret-must-not-render", html);
Assert.Contains("TestService", html);
using var scope = context.App.Services.CreateScope();
var channels = await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync();
Assert.Equal(scenario == "duplicate" ? 1 : 0, channels.Count);
}
[Fact]
public async Task BlankCredentialsCannotChangeProjectAndValidationErrorsDoNotEchoSecrets()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
await SaveNewAsync(client, context.ServiceId, credentials);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/push-channels/{channel.Id}"));
fields["ServiceId"] = context.ServiceId.ToString();
fields["FirebaseProjectId"] = "other-project";
fields["CredentialsJson"] = "";
var response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
Assert.Contains("Changing project requires new credentials", await response.Content.ReadAsStringAsync());
Assert.Equal("test-project", (await channels.GetByIdAsync(channel.Id))!.FirebaseProjectId);
fields["FirebaseProjectId"] = "";
fields["CredentialsJson"] = credentials;
response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
Assert.DoesNotContain("PRIVATE KEY", await response.Content.ReadAsStringAsync());
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
System.Text.Json.Nodes.JsonNode.Parse(credentials),
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
}
[Fact]
public async Task RenameUpdatesDisplayAndInactiveChannelBlocksServiceDeletion()
{
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
var credentials = PushChannelTestCredentials.Create();
await SaveNewAsync(client, context.ServiceId, credentials);
using var scope = context.App.Services.CreateScope();
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
var channel = Assert.Single(await channels.GetAllAsync());
channel.IsActive = false;
await channels.SaveAsync(channel, credentials, false);
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
var service = (await services.GetAsync(context.ServiceId))!;
service.Name = "Invemory-Renamed-Push";
await services.SaveAsync(service, false);
Assert.Contains(service.Name, await client.GetStringAsync("/admin/push-channels"));
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.DeleteAsync(service.Id, service.Revision));
}
private static async Task<HttpResponseMessage> SaveNewAsync(HttpClient client, Guid serviceId, string credentials)
{
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/push-channels/create"));
fields["ServiceId"] = serviceId.ToString();
fields["FirebaseProjectId"] = "test-project";
fields["CredentialsJson"] = credentials;
fields["IsActive"] = "true";
return await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
}
}

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