Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1351279b36 | |||
| aba4173827 | |||
| d4ee8fc0c8 | |||
| 1631786ce2 | |||
| b30dba93e8 |
@@ -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);
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
using System.Net.Mail;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
|
||||
// Parse only service-account fields; never honor caller-supplied OAuth endpoints.
|
||||
public sealed class FirebaseServiceAccountCredentials
|
||||
{
|
||||
public required string ProjectId { get; init; }
|
||||
public required string ClientEmail { get; init; }
|
||||
public required string PrivateKey { get; init; }
|
||||
|
||||
public static bool IsValidProjectId(string projectId) => Regex.IsMatch(projectId,
|
||||
"^[a-z][a-z0-9-]{4,28}[a-z0-9]$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
|
||||
|
||||
public static FirebaseServiceAccountCredentials Parse(string json, string projectId)
|
||||
{
|
||||
if (!IsValidProjectId(projectId) || json.Length > 32768)
|
||||
throw new FormatException("Invalid Firebase configuration.");
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var root = document.RootElement;
|
||||
if (Read(root, "type") != "service_account" || Read(root, "project_id") != projectId)
|
||||
throw new FormatException("The service account must belong to the selected Firebase project.");
|
||||
var email = Read(root, "client_email");
|
||||
if (!MailAddress.TryCreate(email, out var address) || address.Address != email)
|
||||
throw new FormatException("Invalid service account email.");
|
||||
var key = Read(root, "private_key");
|
||||
using var rsa = RSA.Create();
|
||||
rsa.ImportFromPem(key);
|
||||
// Exporting private parameters also rejects a public-key-only PEM.
|
||||
rsa.ExportParameters(true);
|
||||
return new FirebaseServiceAccountCredentials { ProjectId = projectId, ClientEmail = email, PrivateKey = key };
|
||||
}
|
||||
|
||||
private static string Read(JsonElement root, string name)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var value)
|
||||
|| value.ValueKind != JsonValueKind.String)
|
||||
throw new FormatException("Missing service account fields.");
|
||||
return value.GetString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
|
||||
public interface IPushChannelRepository
|
||||
{
|
||||
Task<IReadOnlyList<PushChannel>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<PushChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<PushChannel?> GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default);
|
||||
Task<string?> GetCredentialsAsync(Guid id, CancellationToken ct = default);
|
||||
Task SaveAsync(PushChannel channel, string credentialsJson, bool isNew, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using HrynCo.DAL.Abstract.Entities;
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
|
||||
// Safe administration/read model: credentials are fetched separately by server code.
|
||||
public sealed class PushChannel : Entity
|
||||
{
|
||||
public required Guid ServiceId { get; set; }
|
||||
public string ServiceName { get; set; } = string.Empty;
|
||||
public required string FirebaseProjectId { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
|
||||
public sealed class PushChannelWriteException(string message, bool isConflict = true) : Exception(message)
|
||||
{
|
||||
public bool IsConflict { get; } = isConflict;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
public interface IEmailChannelRepository
|
||||
{
|
||||
Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(Guid serviceId, CancellationToken ct = default);
|
||||
Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default);
|
||||
Task AddAsync(EmailChannel channel, CancellationToken ct = default);
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
|
||||
public interface IPushTemplateDefinitionRepository
|
||||
{
|
||||
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,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");
|
||||
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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; }
|
||||
}
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.EF.Migrations
|
||||
{
|
||||
[DbContext(typeof(NotificationDbContext))]
|
||||
[Migration("20261023223753_AddPushDeliveries")]
|
||||
partial class AddPushDeliveries
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<int?>("DailyLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("daily_limit");
|
||||
|
||||
b.Property<int>("EmailChannelType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("provider_type");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<int?>("MonthlyLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("monthly_limit");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<string>("ServiceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("service_name");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("settings");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.Property<int>("WarnThresholdPercent")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("warn_threshold_percent");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceName", "Priority");
|
||||
|
||||
b.ToTable("email_channels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date");
|
||||
|
||||
b.Property<Guid>("ProviderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("provider_id");
|
||||
|
||||
b.Property<int>("SentCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sent_count");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderId", "Date")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_channel_usage", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<Guid>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_template_definitions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<Guid>("DefinitionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("definition_id");
|
||||
|
||||
b.Property<string>("HtmlBody")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("html_body");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<Guid>("ServiceLanguageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_language_id");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<string>("TextBody")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("text_body");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DefinitionId", "ServiceId");
|
||||
|
||||
b.HasIndex("DefinitionId", "ServiceLanguageId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ServiceLanguageId", "ServiceId");
|
||||
|
||||
b.ToTable("email_templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
|
||||
{
|
||||
b.Property<string>("ServiceName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("InvalidRegistration")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("ServiceName", "MessageId");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("PushDeliveries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("services", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("service_languages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
|
||||
.WithMany("UsageRecords")
|
||||
.HasForeignKey("ProviderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
|
||||
|
||||
b1.Property<int>("__synthesizedOrdinal")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
b1.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasJsonPropertyName("name");
|
||||
|
||||
b1.Property<bool>("Required")
|
||||
.HasJsonPropertyName("required");
|
||||
|
||||
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
|
||||
|
||||
b1.ToTable("email_template_definitions");
|
||||
|
||||
b1
|
||||
.ToJson("variables")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("EmailTemplateDefinitionEntityId");
|
||||
});
|
||||
|
||||
b.Navigation("Service");
|
||||
|
||||
b.Navigation("Variables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
|
||||
.WithMany("Languages")
|
||||
.HasForeignKey("DefinitionId", "ServiceId")
|
||||
.HasPrincipalKey("Id", "ServiceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceLanguageId", "ServiceId")
|
||||
.HasPrincipalKey("Id", "ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Definition");
|
||||
|
||||
b.Navigation("ServiceLanguage");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany("Languages")
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.Navigation("UsageRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.Navigation("Languages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||
{
|
||||
b.Navigation("Languages");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.EF.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPushDeliveries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PushDeliveries",
|
||||
columns: table => new
|
||||
{
|
||||
MessageId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ServiceName = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
|
||||
Error = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
|
||||
InvalidRegistration = table.Column<bool>(type: "boolean", nullable: false),
|
||||
CompletedAt = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PushDeliveries", x => new { x.ServiceName, x.MessageId });
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PushDeliveries_CompletedAt",
|
||||
table: "PushDeliveries",
|
||||
column: "CompletedAt");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PushDeliveries");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+411
@@ -0,0 +1,411 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.EF.Migrations
|
||||
{
|
||||
[DbContext(typeof(NotificationDbContext))]
|
||||
[Migration("20261024073202_LinkEmailChannelsToServices")]
|
||||
partial class LinkEmailChannelsToServices
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<int?>("DailyLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("daily_limit");
|
||||
|
||||
b.Property<int>("EmailChannelType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("provider_type");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<int?>("MonthlyLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("monthly_limit");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("settings");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.Property<int>("WarnThresholdPercent")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("warn_threshold_percent");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Priority");
|
||||
|
||||
b.ToTable("email_channels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date");
|
||||
|
||||
b.Property<Guid>("ProviderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("provider_id");
|
||||
|
||||
b.Property<int>("SentCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sent_count");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderId", "Date")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_channel_usage", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<Guid>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_template_definitions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<Guid>("DefinitionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("definition_id");
|
||||
|
||||
b.Property<string>("HtmlBody")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("html_body");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<Guid>("ServiceLanguageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_language_id");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<string>("TextBody")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("text_body");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DefinitionId", "ServiceId");
|
||||
|
||||
b.HasIndex("DefinitionId", "ServiceLanguageId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ServiceLanguageId", "ServiceId");
|
||||
|
||||
b.ToTable("email_templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
|
||||
{
|
||||
b.Property<string>("ServiceName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("InvalidRegistration")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("ServiceName", "MessageId");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("PushDeliveries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("services", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("service_languages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
|
||||
.WithMany("UsageRecords")
|
||||
.HasForeignKey("ProviderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
|
||||
|
||||
b1.Property<int>("__synthesizedOrdinal")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
b1.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasJsonPropertyName("name");
|
||||
|
||||
b1.Property<bool>("Required")
|
||||
.HasJsonPropertyName("required");
|
||||
|
||||
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
|
||||
|
||||
b1.ToTable("email_template_definitions");
|
||||
|
||||
b1
|
||||
.ToJson("variables")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("EmailTemplateDefinitionEntityId");
|
||||
});
|
||||
|
||||
b.Navigation("Service");
|
||||
|
||||
b.Navigation("Variables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
|
||||
.WithMany("Languages")
|
||||
.HasForeignKey("DefinitionId", "ServiceId")
|
||||
.HasPrincipalKey("Id", "ServiceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceLanguageId", "ServiceId")
|
||||
.HasPrincipalKey("Id", "ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Definition");
|
||||
|
||||
b.Navigation("ServiceLanguage");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany("Languages")
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.Navigation("UsageRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.Navigation("Languages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||
{
|
||||
b.Navigation("Languages");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.EF.Migrations;
|
||||
|
||||
public partial class LinkEmailChannelsToServices : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// EF runs this whole migration in one transaction. Lock the source rows so a
|
||||
// concurrent rename or channel write cannot invalidate the verified mapping.
|
||||
migrationBuilder.Sql("""
|
||||
LOCK TABLE services IN SHARE ROW EXCLUSIVE MODE;
|
||||
LOCK TABLE email_channels IN ACCESS EXCLUSIVE MODE;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM email_channels c
|
||||
WHERE (SELECT count(*) FROM services s
|
||||
WHERE s.name = c.service_name
|
||||
OR (c.service_name = 'StoreMate-Prod' AND s.name = 'Invemory-Prod')) <> 1
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Email channel migration stopped: every legacy service name must have exactly one existing service (including the approved StoreMate-Prod -> Invemory-Prod alias). Resolve missing or ambiguous names before retrying.';
|
||||
END IF;
|
||||
END $$;
|
||||
ALTER TABLE email_channels ADD COLUMN service_id uuid;
|
||||
UPDATE email_channels c SET service_id = s.id
|
||||
FROM services s
|
||||
WHERE s.name = c.service_name
|
||||
OR (c.service_name = 'StoreMate-Prod' AND s.name = 'Invemory-Prod');
|
||||
ALTER TABLE email_channels ALTER COLUMN service_id SET NOT NULL;
|
||||
""");
|
||||
migrationBuilder.DropIndex("IX_email_channels_service_name_priority", "email_channels");
|
||||
migrationBuilder.DropColumn("service_name", "email_channels");
|
||||
migrationBuilder.CreateIndex("IX_email_channels_service_id_priority", "email_channels", new[] { "service_id", "priority" });
|
||||
migrationBuilder.AddForeignKey("FK_email_channels_services_service_id", "email_channels", "service_id",
|
||||
"services", principalColumn: "id", onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Restore current service names; historical names are intentionally not retained.
|
||||
migrationBuilder.Sql("""
|
||||
ALTER TABLE email_channels ADD COLUMN service_name character varying(100);
|
||||
UPDATE email_channels c SET service_name = s.name FROM services s WHERE s.id = c.service_id;
|
||||
ALTER TABLE email_channels ALTER COLUMN service_name SET NOT NULL;
|
||||
""");
|
||||
migrationBuilder.DropForeignKey("FK_email_channels_services_service_id", "email_channels");
|
||||
migrationBuilder.DropIndex("IX_email_channels_service_id_priority", "email_channels");
|
||||
migrationBuilder.DropColumn("service_id", "email_channels");
|
||||
migrationBuilder.CreateIndex("IX_email_channels_service_name_priority", "email_channels", new[] { "service_name", "priority" });
|
||||
}
|
||||
}
|
||||
+464
@@ -0,0 +1,464 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.EF.Migrations
|
||||
{
|
||||
[DbContext(typeof(NotificationDbContext))]
|
||||
[Migration("20261024080457_AddPushChannels")]
|
||||
partial class AddPushChannels
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<int?>("DailyLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("daily_limit");
|
||||
|
||||
b.Property<int>("EmailChannelType")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("provider_type");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<int?>("MonthlyLimit")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("monthly_limit");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("settings");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.Property<int>("WarnThresholdPercent")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("warn_threshold_percent");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Priority");
|
||||
|
||||
b.ToTable("email_channels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("date");
|
||||
|
||||
b.Property<Guid>("ProviderId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("provider_id");
|
||||
|
||||
b.Property<int>("SentCount")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sent_count");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProviderId", "Date")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_channel_usage", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("key");
|
||||
|
||||
b.Property<Guid>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Key")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("email_template_definitions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<Guid>("DefinitionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("definition_id");
|
||||
|
||||
b.Property<string>("HtmlBody")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("html_body");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<Guid>("ServiceLanguageId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_language_id");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<string>("TextBody")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("text_body");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("DefinitionId", "ServiceId");
|
||||
|
||||
b.HasIndex("DefinitionId", "ServiceLanguageId")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("ServiceLanguageId", "ServiceId");
|
||||
|
||||
b.ToTable("email_templates", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<string>("CredentialsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("credentials");
|
||||
|
||||
b.Property<string>("FirebaseProjectId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)")
|
||||
.HasColumnName("firebase_project_id");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("push_channels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushDeliveryEntity", b =>
|
||||
{
|
||||
b.Property<string>("ServiceName")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("character varying(128)");
|
||||
|
||||
b.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CompletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Error")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<bool>("InvalidRegistration")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("ServiceName", "MessageId");
|
||||
|
||||
b.HasIndex("CompletedAt");
|
||||
|
||||
b.ToTable("PushDeliveries", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid>("Revision")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("revision");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("services", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(10)
|
||||
.HasColumnType("character varying(10)")
|
||||
.HasColumnName("code");
|
||||
|
||||
b.Property<DateTimeOffset>("Created")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created");
|
||||
|
||||
b.Property<Guid>("ServiceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("service_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("Updated")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ServiceId", "Code")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("service_languages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelUsageEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", null)
|
||||
.WithMany("UsageRecords")
|
||||
.HasForeignKey("ProviderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsMany("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateVariableData", "Variables", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("EmailTemplateDefinitionEntityId");
|
||||
|
||||
b1.Property<int>("__synthesizedOrdinal")
|
||||
.ValueGeneratedOnAdd();
|
||||
|
||||
b1.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasJsonPropertyName("name");
|
||||
|
||||
b1.Property<bool>("Required")
|
||||
.HasJsonPropertyName("required");
|
||||
|
||||
b1.HasKey("EmailTemplateDefinitionEntityId", "__synthesizedOrdinal");
|
||||
|
||||
b1.ToTable("email_template_definitions");
|
||||
|
||||
b1
|
||||
.ToJson("variables")
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("EmailTemplateDefinitionEntityId");
|
||||
});
|
||||
|
||||
b.Navigation("Service");
|
||||
|
||||
b.Navigation("Variables");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", "Definition")
|
||||
.WithMany("Languages")
|
||||
.HasForeignKey("DefinitionId", "ServiceId")
|
||||
.HasPrincipalKey("Id", "ServiceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", "ServiceLanguage")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceLanguageId", "ServiceId")
|
||||
.HasPrincipalKey("Id", "ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Definition");
|
||||
|
||||
b.Navigation("ServiceLanguage");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.PushChannelEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany()
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceLanguageEntity", b =>
|
||||
{
|
||||
b.HasOne("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", "Service")
|
||||
.WithMany("Languages")
|
||||
.HasForeignKey("ServiceId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Service");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailChannelEntity", b =>
|
||||
{
|
||||
b.Navigation("UsageRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.EmailTemplateDefinitionEntity", b =>
|
||||
{
|
||||
b.Navigation("Languages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("HrynCo.NotificationService.DAL.EF.Entities.ServiceEntity", b =>
|
||||
{
|
||||
b.Navigation("Languages");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.EF.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPushChannels : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "push_channels",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
service_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
firebase_project_id = table.Column<string>(type: "character varying(30)", maxLength: 30, nullable: false),
|
||||
credentials = table.Column<string>(type: "jsonb", nullable: false),
|
||||
is_active = table.Column<bool>(type: "boolean", nullable: false),
|
||||
created = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
updated = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_push_channels", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_push_channels_services_service_id",
|
||||
column: x => x.service_id,
|
||||
principalTable: "services",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_push_channels_service_id",
|
||||
table: "push_channels",
|
||||
column: "service_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "push_channels");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+674
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+679
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+309
-6
@@ -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,108 @@
|
||||
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(async () =>
|
||||
{
|
||||
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;
|
||||
}, ct);
|
||||
|
||||
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);
|
||||
}
|
||||
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)
|
||||
{
|
||||
var removed = false;
|
||||
await mutations.ExecuteAsync(async () =>
|
||||
{
|
||||
var entity = await Definitions.SingleOrDefaultAsync(d => d.ServiceId == serviceId && d.Key == key, ct);
|
||||
if (entity is null)
|
||||
return;
|
||||
if (revision == Guid.Empty || 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));
|
||||
removed = true;
|
||||
}, ct);
|
||||
return removed;
|
||||
}
|
||||
|
||||
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" &&
|
||||
|
||||
+19
-4
@@ -12,7 +12,7 @@ public sealed class SendEmailDeliveryValidatorTests
|
||||
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||
CreateMessage(),
|
||||
CreateContext(),
|
||||
SendEmailConsumer.SupportedMessageType);
|
||||
SupportedMessageType.SendEmailV1);
|
||||
|
||||
Assert.Null(error);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ public sealed class SendEmailDeliveryValidatorTests
|
||||
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||
CreateMessage(),
|
||||
context,
|
||||
SendEmailConsumer.SupportedMessageType);
|
||||
SupportedMessageType.SendEmailV1);
|
||||
|
||||
Assert.Equal("AMQP MessageId is required.", error);
|
||||
}
|
||||
@@ -41,18 +41,33 @@ public sealed class SendEmailDeliveryValidatorTests
|
||||
string? error = SendEmailDeliveryValidator.GetValidationError(
|
||||
CreateMessage(),
|
||||
context,
|
||||
SendEmailConsumer.SupportedMessageType);
|
||||
SupportedMessageType.SendEmailV1);
|
||||
|
||||
Assert.Equal("Unsupported AMQP message type 'Notification.SendEmail.v2'.", error);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Notification.SendPush.v1")]
|
||||
[InlineData("notification.sendemail.v1")]
|
||||
[InlineData("SendEmailV1")]
|
||||
[InlineData("0")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void OtherOrMissingWireType_IsRejected(string? wireType)
|
||||
{
|
||||
string? error = SendEmailDeliveryValidator.GetValidationError(CreateMessage(),
|
||||
CreateContext() with { MessageType = wireType }, SupportedMessageType.SendEmailV1);
|
||||
|
||||
Assert.Equal($"Unsupported AMQP message type '{wireType ?? "<missing>"}'.", error);
|
||||
}
|
||||
|
||||
private static RabbitMqMessageContext CreateContext() => new()
|
||||
{
|
||||
QueueName = SendEmailConsumer.IncomingQueue,
|
||||
Exchange = string.Empty,
|
||||
RoutingKey = SendEmailConsumer.IncomingQueue,
|
||||
MessageId = Guid.NewGuid().ToString("D"),
|
||||
MessageType = SendEmailConsumer.SupportedMessageType,
|
||||
MessageType = "Notification.SendEmail.v1",
|
||||
CorrelationId = "correlation-id",
|
||||
ContentType = "application/json"
|
||||
};
|
||||
|
||||
@@ -23,12 +23,12 @@ public sealed class SendEmailServiceTests
|
||||
Password = "not-a-real-secret",
|
||||
UseSsl = true,
|
||||
FromEmail = "notifications@example.com",
|
||||
FromName = "StoreMate"
|
||||
FromName = "Invemory"
|
||||
};
|
||||
var channel = new EmailChannel
|
||||
{
|
||||
Id = channelId,
|
||||
ServiceName = "StoreMate-Prod",
|
||||
ServiceId = Guid.NewGuid(),
|
||||
Priority = 1,
|
||||
EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = settings,
|
||||
@@ -36,7 +36,7 @@ public sealed class SendEmailServiceTests
|
||||
};
|
||||
var template = new EmailTemplate
|
||||
{
|
||||
ServiceName = "StoreMate-Prod",
|
||||
ServiceName = "Invemory-Prod",
|
||||
Key = "EmailVerification",
|
||||
LanguageCode = "uk",
|
||||
Subject = "Verify {{AppName}}",
|
||||
@@ -46,17 +46,21 @@ public sealed class SendEmailServiceTests
|
||||
};
|
||||
|
||||
IEmailChannelRepository channels = Substitute.For<IEmailChannelRepository>();
|
||||
channels.GetByServiceAsync("StoreMate-Prod", Arg.Any<CancellationToken>())
|
||||
channels.GetByServiceAsync(channel.ServiceId, Arg.Any<CancellationToken>())
|
||||
.Returns([channel]);
|
||||
var services = Substitute.For<INotificationServiceRepository>();
|
||||
services.GetByNameAsync("Invemory-Prod", Arg.Any<CancellationToken>())
|
||||
.Returns(new NotificationServiceInfo { Id = channel.ServiceId, Name = "Invemory-Prod" });
|
||||
IEmailChannelUsageRepository usage = Substitute.For<IEmailChannelUsageRepository>();
|
||||
IEmailTemplateService templates = Substitute.For<IEmailTemplateService>();
|
||||
templates.GetAsync("StoreMate-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
|
||||
templates.GetAsync("Invemory-Prod", "EmailVerification", "uk", Arg.Any<CancellationToken>())
|
||||
.Returns(template);
|
||||
var renderer = new EmailTemplateRenderingService();
|
||||
var smtp = new RecordingSmtpEmailSender();
|
||||
INotificationResultPublisher resultPublisher = Substitute.For<INotificationResultPublisher>();
|
||||
var service = new SendEmailService(
|
||||
channels,
|
||||
services,
|
||||
usage,
|
||||
templates,
|
||||
renderer,
|
||||
@@ -71,12 +75,12 @@ public sealed class SendEmailServiceTests
|
||||
},
|
||||
Data = new SendEmailMessageData
|
||||
{
|
||||
ServiceName = "StoreMate-Prod",
|
||||
ServiceName = "Invemory-Prod",
|
||||
TemplateKey = "EmailVerification",
|
||||
RecipientEmail = "owner@example.com",
|
||||
RecipientName = "Owner",
|
||||
LanguageCode = "uk",
|
||||
Variables = new Dictionary<string, string> { ["AppName"] = "StoreMate" }
|
||||
Variables = new Dictionary<string, string> { ["AppName"] = "Invemory" }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,7 +88,7 @@ public sealed class SendEmailServiceTests
|
||||
|
||||
Assert.Equal(1, smtp.SendCount);
|
||||
Assert.Same(settings, smtp.Settings);
|
||||
Assert.Equal("Verify StoreMate", smtp.Email?.Subject);
|
||||
Assert.Equal("Verify Invemory", smtp.Email?.Subject);
|
||||
Assert.Equal("owner@example.com", smtp.RecipientEmail);
|
||||
Assert.Equal("Owner", smtp.RecipientName);
|
||||
await usage.Received(1).IncrementUsageAsync(
|
||||
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -5,7 +5,7 @@ using HrynCo.NotificationService.Services.Core;
|
||||
namespace HrynCo.NotificationService.Services.EmailChannels.Create;
|
||||
|
||||
public sealed record CreateEmailChannelCommand(
|
||||
string ServiceName,
|
||||
Guid ServiceId,
|
||||
int Priority,
|
||||
EmailChannelType ChannelType,
|
||||
EmailChannelSettings Settings,
|
||||
|
||||
+8
-3
@@ -1,4 +1,3 @@
|
||||
using HrynCo.DAL.Abstract;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
@@ -11,21 +10,27 @@ internal sealed class CreateEmailChannelHandler
|
||||
: RequestHandler<CreateEmailChannelCommand, ServiceResult<Guid>>
|
||||
{
|
||||
private readonly IEmailChannelRepository _channels;
|
||||
private readonly INotificationServiceRepository _services;
|
||||
|
||||
public CreateEmailChannelHandler(
|
||||
IContextualSerilogLogger<CreateEmailChannelCommand> logger,
|
||||
IEmailChannelRepository channels)
|
||||
IEmailChannelRepository channels,
|
||||
INotificationServiceRepository services)
|
||||
: base(logger)
|
||||
{
|
||||
_channels = channels;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
protected override async Task<ServiceResult<Guid>> DoOnHandle(
|
||||
CreateEmailChannelCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
|
||||
return Failure<Guid>("Select an existing service.", ServiceErrorCode.InvalidRequest);
|
||||
|
||||
var channel = new EmailChannel
|
||||
{
|
||||
ServiceName = request.ServiceName,
|
||||
ServiceId = request.ServiceId,
|
||||
Priority = request.Priority,
|
||||
EmailChannelType = request.ChannelType,
|
||||
Settings = request.Settings,
|
||||
|
||||
+8
-2
@@ -10,19 +10,25 @@ internal sealed class GetEmailChannelsHandler
|
||||
: RequestHandler<GetEmailChannelsQuery, ServiceResult<IReadOnlyList<EmailChannel>>>
|
||||
{
|
||||
private readonly IEmailChannelRepository _channels;
|
||||
private readonly INotificationServiceRepository _services;
|
||||
|
||||
public GetEmailChannelsHandler(
|
||||
IContextualSerilogLogger<GetEmailChannelsQuery> logger,
|
||||
IEmailChannelRepository channels)
|
||||
IEmailChannelRepository channels,
|
||||
INotificationServiceRepository services)
|
||||
: base(logger)
|
||||
{
|
||||
_channels = channels;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
protected override async Task<ServiceResult<IReadOnlyList<EmailChannel>>> DoOnHandle(
|
||||
GetEmailChannelsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var channels = await _channels.GetByServiceAsync(request.ServiceName, cancellationToken);
|
||||
var service = await _services.GetByNameAsync(request.ServiceName, cancellationToken);
|
||||
if (service is null)
|
||||
return Success<IReadOnlyList<EmailChannel>>([]);
|
||||
var channels = await _channels.GetByServiceAsync(service.Id, cancellationToken);
|
||||
return Success(channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace HrynCo.NotificationService.Services.EmailChannels.Update;
|
||||
|
||||
public sealed record UpdateEmailChannelCommand(
|
||||
Guid Id,
|
||||
Guid ServiceId,
|
||||
int Priority,
|
||||
EmailChannelSettings Settings,
|
||||
int? DailyLimit,
|
||||
|
||||
+8
-2
@@ -1,4 +1,3 @@
|
||||
using HrynCo.DAL.Abstract;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
using HrynCo.NotificationService.Services.Logging;
|
||||
@@ -10,13 +9,16 @@ internal sealed class UpdateEmailChannelHandler
|
||||
: RequestHandler<UpdateEmailChannelCommand, ServiceResult<Unit>>
|
||||
{
|
||||
private readonly IEmailChannelRepository _channels;
|
||||
private readonly INotificationServiceRepository _services;
|
||||
|
||||
public UpdateEmailChannelHandler(
|
||||
IContextualSerilogLogger<UpdateEmailChannelCommand> logger,
|
||||
IEmailChannelRepository channels)
|
||||
IEmailChannelRepository channels,
|
||||
INotificationServiceRepository services)
|
||||
: base(logger)
|
||||
{
|
||||
_channels = channels;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
protected override async Task<ServiceResult<Unit>> DoOnHandle(
|
||||
@@ -27,6 +29,10 @@ internal sealed class UpdateEmailChannelHandler
|
||||
if (channel is null)
|
||||
return Failure<Unit>("Email channel not found.", ServiceErrorCode.NotFound);
|
||||
|
||||
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
|
||||
return Failure<Unit>("Select an existing service.", ServiceErrorCode.InvalidRequest);
|
||||
|
||||
channel.ServiceId = request.ServiceId;
|
||||
channel.Priority = request.Priority;
|
||||
channel.Settings = request.Settings;
|
||||
channel.DailyLimit = request.DailyLimit;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
using MediatR;
|
||||
|
||||
namespace HrynCo.NotificationService.Services.PushChannels;
|
||||
|
||||
// A class deliberately avoids record-generated ToString exposing the submitted secret.
|
||||
public sealed class SavePushChannelCommand : IRequest<ServiceResult<Guid>>
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ServiceId { get; init; }
|
||||
public required string FirebaseProjectId { get; init; }
|
||||
public string? CredentialsJson { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
using HrynCo.NotificationService.Services.Logging;
|
||||
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
|
||||
|
||||
namespace HrynCo.NotificationService.Services.PushChannels;
|
||||
|
||||
internal sealed class SavePushChannelHandler(
|
||||
IContextualSerilogLogger<SavePushChannelCommand> logger, IPushChannelRepository channels)
|
||||
: RequestHandler<SavePushChannelCommand, ServiceResult<Guid>>(logger)
|
||||
{
|
||||
protected override async Task<ServiceResult<Guid>> DoOnHandle(SavePushChannelCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await channels.GetByIdAsync(request.Id, cancellationToken);
|
||||
if (request.Id != Guid.Empty && existing is null)
|
||||
return Failure<Guid>("Push channel not found.", ServiceErrorCode.NotFound);
|
||||
var projectId = request.FirebaseProjectId.Trim();
|
||||
var credentials = await ResolveCredentialsAsync(request, existing, projectId, cancellationToken);
|
||||
if (!ValidCredentials(credentials, projectId))
|
||||
return Failure<Guid>("Provide a valid Firebase service account JSON for this project. Changing project requires new credentials.", ServiceErrorCode.InvalidRequest);
|
||||
var channel = new PushChannel
|
||||
{
|
||||
Id = existing?.Id ?? Guid.NewGuid(), ServiceId = request.ServiceId,
|
||||
FirebaseProjectId = projectId, IsActive = request.IsActive
|
||||
};
|
||||
return await SaveAsync(channel, credentials!, existing is null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveCredentialsAsync(SavePushChannelCommand request, PushChannel? existing,
|
||||
string projectId, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.CredentialsJson))
|
||||
return request.CredentialsJson;
|
||||
if (existing is null || existing.FirebaseProjectId != projectId)
|
||||
return null;
|
||||
return await channels.GetCredentialsAsync(existing.Id, ct);
|
||||
}
|
||||
|
||||
private static bool ValidCredentials(string? json, string projectId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
||||
try
|
||||
{
|
||||
FirebaseServiceAccountCredentials.Parse(json, projectId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
|
||||
{
|
||||
// Neither parser exceptions nor submitted JSON are sent to logs or clients.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ServiceResult<Guid>> SaveAsync(PushChannel channel, string credentials, bool isNew, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await channels.SaveAsync(channel, credentials, isNew, ct);
|
||||
return Success(channel.Id);
|
||||
}
|
||||
catch (PushChannelWriteException error)
|
||||
{
|
||||
return Failure<Guid>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ public sealed class AdminDeletionTests(TemplateDatabaseFixture fixture) : IClass
|
||||
var url = $"/admin/services/{context.ServiceId}/delete";
|
||||
var blocked = await client.GetStringAsync(url);
|
||||
Assert.Contains("1 template definition(s)", blocked);
|
||||
Assert.Contains("Email channels are retained", blocked);
|
||||
Assert.Contains("Channels are never deleted automatically", blocked);
|
||||
var fields = TemplateEditorTestFields.Read(blocked);
|
||||
fields["ConfirmDelete"] = "true";
|
||||
Assert.Contains("Remove every template definition", await (await client.PostAsync(url, new FormUrlEncodedContent(fields))).Content.ReadAsStringAsync());
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
|
||||
var channel = await SeedChannel(context);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
|
||||
fields["ServiceId"] = context.ServiceId.ToString();
|
||||
fields["UseSsl"] = "true";
|
||||
fields["Password"] = "";
|
||||
fields["FromName"] = "Updated sender";
|
||||
@@ -38,6 +39,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
|
||||
var channel = await SeedChannel(context);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
|
||||
fields["ServiceId"] = context.ServiceId.ToString();
|
||||
fields["UseSsl"] = "true";
|
||||
fields["Password"] = "";
|
||||
fields[field] = value;
|
||||
@@ -60,6 +62,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
|
||||
var channel = await SeedChannel(context);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/channels/{channel.Id}"));
|
||||
fields["ServiceId"] = context.ServiceId.ToString();
|
||||
fields["UseSsl"] = "true";
|
||||
fields["Password"] = "replacement-test-secret";
|
||||
fields["Host"] = "other.example.invalid";
|
||||
@@ -78,7 +81,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/channels/create"));
|
||||
fields["ServiceName"] = "NewTestService";
|
||||
fields["ServiceId"] = context.ServiceId.ToString();
|
||||
fields["Host"] = "smtp.example.invalid";
|
||||
fields["Username"] = "test-user";
|
||||
fields["FromEmail"] = "sender@example.invalid";
|
||||
@@ -114,7 +117,7 @@ public sealed class AdminSmtpSaveTests(TemplateDatabaseFixture fixture) : IClass
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var channel = new EmailChannel
|
||||
{
|
||||
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
|
||||
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = new SmtpChannelSettings
|
||||
{
|
||||
Host = "smtp.example.invalid", Port = 587, Username = "test-user",
|
||||
|
||||
@@ -89,7 +89,7 @@ public sealed class AdminSmtpTestControllerTests
|
||||
recorder.Channel = new EmailChannel
|
||||
{
|
||||
Id = ChannelId,
|
||||
ServiceName = "TestService",
|
||||
ServiceId = Guid.NewGuid(),
|
||||
EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = new SmtpChannelSettings
|
||||
{
|
||||
@@ -97,7 +97,7 @@ public sealed class AdminSmtpTestControllerTests
|
||||
Username = "test-user", Password = "saved-test-secret", UseSsl = true
|
||||
}
|
||||
};
|
||||
return (new AdminChannelsController(mediator), recorder);
|
||||
return (new AdminChannelsController(mediator, null!), recorder);
|
||||
}
|
||||
|
||||
private static TestSmtpRequest Request() => new(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -22,7 +22,7 @@ public sealed class InterimEmailTemplateMigrationTests(TemplateDatabaseFixture f
|
||||
Assert.Equal(firstServices, await ScalarAsync(connection, "SELECT jsonb_agg(to_jsonb(s) ORDER BY id)::text FROM services s"));
|
||||
await using var db = TemplateDatabaseFixture.CreateContext(connection);
|
||||
var history = (await db.Database.GetAppliedMigrationsAsync()).ToList();
|
||||
Assert.Equal(4, history.Count);
|
||||
Assert.Equal(db.Database.GetMigrations().Append(InterimEmailTemplateFixture.MigrationId).Order(), history.Order());
|
||||
Assert.Contains(InterimEmailTemplateFixture.MigrationId, history);
|
||||
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
|
||||
using var scope = provider.CreateScope();
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class PushChannelAdminTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestsRequireAntiforgeryAndRejectMissingCredentialsWithoutSaving()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient();
|
||||
var html = await client.GetStringAsync("/admin/push-channels/create");
|
||||
Assert.Contains("Check Firebase authorization", html);
|
||||
Assert.Contains("Recipient token", html);
|
||||
Assert.Contains("test-authorization", html);
|
||||
var fields = TemplateEditorTestFields.Read(html);
|
||||
foreach (var action in new[] { "test-authorization", "test-send" })
|
||||
{
|
||||
var body = new { firebaseProjectId = "test-project", recipientToken = "test-token" };
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsJsonAsync("/admin/push-channels/" + action, body)).StatusCode);
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/" + action);
|
||||
request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]);
|
||||
request.Content = JsonContent.Create(body);
|
||||
var response = await client.SendAsync(request);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var result = await response.Content.ReadFromJsonAsync<System.Text.Json.JsonElement>();
|
||||
Assert.False(result.GetProperty("success").GetBoolean());
|
||||
Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync());
|
||||
}
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAndEditKeepCredentialsOnServerAndAllowServiceReassignment()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var credentials = PushChannelTestCredentials.Create();
|
||||
var response = await SaveNewAsync(client, context.ServiceId, credentials);
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
|
||||
var channel = Assert.Single(await channels.GetAllAsync());
|
||||
var other = new NotificationServiceInfo { Name = "Invemory-Push-Test" };
|
||||
await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
|
||||
var html = await client.GetStringAsync($"/admin/push-channels/{channel.Id}");
|
||||
Assert.DoesNotContain("PRIVATE KEY", html);
|
||||
Assert.DoesNotContain("test@example.invalid", html);
|
||||
Assert.Contains("Credentials are saved", html);
|
||||
var fields = TemplateEditorTestFields.Read(html);
|
||||
fields["ServiceId"] = other.Id.ToString();
|
||||
fields["CredentialsJson"] = "";
|
||||
fields["IsActive"] = "true";
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields))).StatusCode);
|
||||
var saved = (await channels.GetByIdAsync(channel.Id))!;
|
||||
Assert.Equal(other.Id, saved.ServiceId);
|
||||
Assert.Equal("Invemory-Push-Test", saved.ServiceName);
|
||||
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
|
||||
System.Text.Json.Nodes.JsonNode.Parse(credentials),
|
||||
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
|
||||
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
|
||||
Assert.NotNull(await channels.GetActiveForServiceAsync(other.Id));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("duplicate")]
|
||||
[InlineData("missingService")]
|
||||
[InlineData("emptyService")]
|
||||
[InlineData("invalidJson")]
|
||||
[InlineData("missingCredentials")]
|
||||
public async Task InvalidCreateDoesNotExposeSubmittedSecretOrPersistAChannel(string scenario)
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var credentials = PushChannelTestCredentials.Create();
|
||||
if (scenario == "duplicate")
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await SaveNewAsync(client, context.ServiceId, credentials)).StatusCode);
|
||||
var serviceId = scenario switch { "missingService" => Guid.NewGuid(), "emptyService" => Guid.Empty, _ => context.ServiceId };
|
||||
var secret = scenario switch { "invalidJson" => "invalid-secret-must-not-render", "missingCredentials" => "", _ => credentials };
|
||||
var response = await SaveNewAsync(client, serviceId, secret);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.DoesNotContain("PRIVATE KEY", html);
|
||||
Assert.DoesNotContain("invalid-secret-must-not-render", html);
|
||||
Assert.Contains("TestService", html);
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var channels = await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync();
|
||||
Assert.Equal(scenario == "duplicate" ? 1 : 0, channels.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BlankCredentialsCannotChangeProjectAndValidationErrorsDoNotEchoSecrets()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var credentials = PushChannelTestCredentials.Create();
|
||||
await SaveNewAsync(client, context.ServiceId, credentials);
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
|
||||
var channel = Assert.Single(await channels.GetAllAsync());
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/push-channels/{channel.Id}"));
|
||||
fields["ServiceId"] = context.ServiceId.ToString();
|
||||
fields["FirebaseProjectId"] = "other-project";
|
||||
fields["CredentialsJson"] = "";
|
||||
var response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
|
||||
Assert.Contains("Changing project requires new credentials", await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal("test-project", (await channels.GetByIdAsync(channel.Id))!.FirebaseProjectId);
|
||||
fields["FirebaseProjectId"] = "";
|
||||
fields["CredentialsJson"] = credentials;
|
||||
response = await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
|
||||
Assert.DoesNotContain("PRIVATE KEY", await response.Content.ReadAsStringAsync());
|
||||
Assert.True(System.Text.Json.Nodes.JsonNode.DeepEquals(
|
||||
System.Text.Json.Nodes.JsonNode.Parse(credentials),
|
||||
System.Text.Json.Nodes.JsonNode.Parse((await channels.GetCredentialsAsync(channel.Id))!)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RenameUpdatesDisplayAndInactiveChannelBlocksServiceDeletion()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
using var client = context.App.CreateClient(new() { AllowAutoRedirect = false });
|
||||
var credentials = PushChannelTestCredentials.Create();
|
||||
await SaveNewAsync(client, context.ServiceId, credentials);
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
var channels = scope.ServiceProvider.GetRequiredService<IPushChannelRepository>();
|
||||
var channel = Assert.Single(await channels.GetAllAsync());
|
||||
channel.IsActive = false;
|
||||
await channels.SaveAsync(channel, credentials, false);
|
||||
Assert.Null(await channels.GetActiveForServiceAsync(context.ServiceId));
|
||||
var services = scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>();
|
||||
var service = (await services.GetAsync(context.ServiceId))!;
|
||||
service.Name = "Invemory-Renamed-Push";
|
||||
await services.SaveAsync(service, false);
|
||||
Assert.Contains(service.Name, await client.GetStringAsync("/admin/push-channels"));
|
||||
await Assert.ThrowsAsync<EmailTemplateWriteException>(() => services.DeleteAsync(service.Id, service.Revision));
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseMessage> SaveNewAsync(HttpClient client, Guid serviceId, string credentials)
|
||||
{
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync("/admin/push-channels/create"));
|
||||
fields["ServiceId"] = serviceId.ToString();
|
||||
fields["FirebaseProjectId"] = "test-project";
|
||||
fields["CredentialsJson"] = credentials;
|
||||
fields["IsActive"] = "true";
|
||||
return await client.PostAsync("/admin/push-channels/save", new FormUrlEncodedContent(fields));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.Worker.Services.Push;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class PushChannelFormatTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(1, false, true)]
|
||||
[InlineData(2, true, true)]
|
||||
[InlineData(2, false, false)]
|
||||
[InlineData(3, true, false)]
|
||||
public async Task TestEndpointSelectsFormatAndValidatesBeforeProviderWithoutChangingChannel(int version, bool hasBinding, bool accepted)
|
||||
{
|
||||
await using var factory = new TemplateWebApplicationFactory(await fixture.CreateDatabaseAsync());
|
||||
var firebase = new RecordingFirebasePushClient();
|
||||
await using var app = factory.WithWebHostBuilder(builder => builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.RemoveAll<IFirebasePushClient>();
|
||||
services.AddSingleton<IFirebasePushClient>(firebase);
|
||||
}));
|
||||
using var client = app.CreateClient();
|
||||
var html = await client.GetStringAsync("/admin/push-channels/create");
|
||||
Assert.Contains("Version 2 — notification with a button", html);
|
||||
var fields = TemplateEditorTestFields.Read(html);
|
||||
var binding = hasBinding ? Guid.NewGuid() : (Guid?)null;
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "/admin/push-channels/test-send");
|
||||
request.Headers.Add("RequestVerificationToken", fields["__RequestVerificationToken"]);
|
||||
request.Content = JsonContent.Create(new
|
||||
{
|
||||
firebaseProjectId = "test-project", credentialsJson = PushChannelTestCredentials.Create(),
|
||||
recipientToken = "test-token", version, bindingId = binding, actionKey = "openBasket", buttonLabel = "Перейти в розділ"
|
||||
});
|
||||
var response = await client.SendAsync(request);
|
||||
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal(accepted, result.GetProperty("success").GetBoolean());
|
||||
Assert.DoesNotContain("test-token", await response.Content.ReadAsStringAsync());
|
||||
if (accepted)
|
||||
{
|
||||
var sent = Assert.Single(firebase.Messages);
|
||||
Assert.Equal(version, sent.Version);
|
||||
Assert.Equal(binding, sent.BindingId);
|
||||
Assert.Equal("Перейти в розділ", sent.ButtonLabel);
|
||||
}
|
||||
else Assert.Empty(firebase.Messages);
|
||||
using var scope = app.Services.CreateScope();
|
||||
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushChannelRepository>().GetAllAsync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
internal static class PushChannelTestCredentials
|
||||
{
|
||||
public static string Create(string projectId = "test-project")
|
||||
{
|
||||
using var rsa = RSA.Create(2048);
|
||||
return JsonSerializer.Serialize(new
|
||||
{
|
||||
type = "service_account", project_id = projectId,
|
||||
client_email = "test@example.invalid", private_key = rsa.ExportPkcs8PrivateKeyPem()
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
public sealed class PushDeliveryRepositoryTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task RedeliveryReplaysOutcomeAndFailedProviderCallCanRetry()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
using var provider = new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
|
||||
var id = Guid.NewGuid();
|
||||
using (var first = provider.CreateScope())
|
||||
{
|
||||
var repository = first.ServiceProvider.GetRequiredService<IPushDeliveryRepository>();
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => repository.DeliverOnceAsync(id, "Invemory",
|
||||
_ => throw new InvalidOperationException("transient"), default));
|
||||
}
|
||||
using (var retry = provider.CreateScope())
|
||||
{
|
||||
var repository = retry.ServiceProvider.GetRequiredService<IPushDeliveryRepository>();
|
||||
var result = await repository.DeliverOnceAsync(id, "Invemory",
|
||||
_ => Task.FromResult(new PushDeliveryOutcome("unregistered", true)), default);
|
||||
Assert.True(result.InvalidRegistration);
|
||||
}
|
||||
using var redelivery = provider.CreateScope();
|
||||
var saved = await redelivery.ServiceProvider.GetRequiredService<IPushDeliveryRepository>()
|
||||
.DeliverOnceAsync(id, "Invemory", _ => throw new Exception("must not send twice"), default);
|
||||
Assert.Equal("unregistered", saved.Error);
|
||||
Assert.True(saved.InvalidRegistration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Net;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.DAL.EF;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class PushTemplateEditorTests(TemplateDatabaseFixture fixture) : IClassFixture<TemplateDatabaseFixture>
|
||||
{
|
||||
[Fact]
|
||||
public async Task PushOnlyKeyCanAcquireEmailWithoutChangingPush_AndListHasOneRow()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var service = await ReadServiceAsync(connection);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
|
||||
fields["Key"] = "PushOnly";
|
||||
SetPush(fields, "en", "Hello {{Name}}", "A shared item changed.");
|
||||
fields["VariablesJson"] = "[{\"name\":\"Name\",\"required\":true}]";
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields))).StatusCode);
|
||||
var missing = await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/email?key=PushOnly");
|
||||
Assert.Contains("Create Email Template", missing);
|
||||
var emailFields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/templates/create?templateKey=PushOnly"));
|
||||
Assert.Equal("PushOnly", emailFields["Key"]);
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/templates/save", new FormUrlEncodedContent(emailFields))).StatusCode);
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
var push = await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "PushOnly");
|
||||
Assert.Equal("Hello {{Name}}", Assert.Single(push!.Languages).Title);
|
||||
var email = await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync(service.Id, "PushOnly");
|
||||
Assert.Empty(email!.Variables);
|
||||
var list = await client.GetStringAsync($"/admin/services/{service.Id}");
|
||||
Assert.Contains("<th scope=\"col\">Email</th>", list);
|
||||
Assert.Contains("<th scope=\"col\">Push</th>", list);
|
||||
Assert.Equal(1, System.Text.RegularExpressions.Regex.Matches(list, "<td>PushOnly</td>").Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("partial")]
|
||||
[InlineData("placeholder")]
|
||||
[InlineData("json")]
|
||||
[InlineData("catalog")]
|
||||
public async Task InvalidSaveRetainsInputAndDoesNotCreatePush(string scenario)
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient();
|
||||
var service = await ReadServiceAsync(connection);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
|
||||
fields["Key"] = "Invalid";
|
||||
SetPush(fields, "en", "Retained title", "Body");
|
||||
if (scenario == "partial") SetPush(fields, "en", "Retained title", "");
|
||||
if (scenario == "placeholder") SetPush(fields, "en", "Retained title", "{{Unknown}}");
|
||||
if (scenario == "json") fields["VariablesJson"] = "{}";
|
||||
if (scenario == "catalog") fields["Languages[0].LanguageCode"] = "foreign";
|
||||
var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
|
||||
var html = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Retained title", html);
|
||||
Assert.Contains("validation-summary-errors", html);
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
Assert.Null(await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Invalid"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingTabHasNoSideEffects_StaleSaveIsRejected_AndFinalVersionCanBeRemoved()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var service = await ReadServiceAsync(connection);
|
||||
Assert.Contains("Create Push Template", await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
|
||||
using (var provider = Provider(connection))
|
||||
using (var scope = provider.CreateScope())
|
||||
Assert.Empty(await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetDefinitionsAsync(service.Id));
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create?key=Shared"));
|
||||
SetPush(fields, "uk", "Title", "Body");
|
||||
await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
|
||||
var saved = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
|
||||
var stale = new Dictionary<string, string>(saved);
|
||||
SetPush(saved, "uk", "Updated title", "Updated body");
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(saved))).StatusCode);
|
||||
SetPush(stale, "uk", "Retained stale input", "Body");
|
||||
var error = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(stale));
|
||||
Assert.Contains("changed after you opened", await error.Content.ReadAsStringAsync());
|
||||
Assert.Contains("Retained stale input", await error.Content.ReadAsStringAsync());
|
||||
var current = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=Shared"));
|
||||
SetPush(current, "uk", "", "");
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(current))).StatusCode);
|
||||
using var finalProvider = Provider(connection);
|
||||
using var finalScope = finalProvider.CreateScope();
|
||||
Assert.Empty((await finalScope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Shared"))!.Languages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmedPushDeletionKeepsEmail_AndPostRequiresAntiforgery()
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
var service = await ReadServiceAsync(connection);
|
||||
using (var provider = Provider(connection))
|
||||
using (var scope = provider.CreateScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Shared" }, true);
|
||||
await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().SaveAsync(new PushTemplateDefinition { ServiceId = service.Id, Key = "Shared" }, true);
|
||||
}
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var route = $"/admin/services/{service.Id}/push-templates/delete";
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync(route, new FormUrlEncodedContent(new Dictionary<string, string> { ["Key"] = "Shared" }))).StatusCode);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync(route + "?key=Shared"));
|
||||
fields["ConfirmDelete"] = "true";
|
||||
Assert.Equal(HttpStatusCode.Redirect, (await client.PostAsync(route, new FormUrlEncodedContent(fields))).StatusCode);
|
||||
using var readProvider = Provider(connection);
|
||||
using var readScope = readProvider.CreateScope();
|
||||
Assert.NotNull(await readScope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>().GetDefinitionAsync(service.Id, "Shared"));
|
||||
Assert.Null(await readScope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "Shared"));
|
||||
}
|
||||
|
||||
private static ServiceProvider Provider(string connection) => new ServiceCollection().AddNotificationDataAccess(connection).BuildServiceProvider();
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task LocalizedActionsRoundTripAndInvalidEditsRetainInput(bool invalid)
|
||||
{
|
||||
var connection = await fixture.CreateDatabaseAsync();
|
||||
await using var app = new TemplateWebApplicationFactory(connection);
|
||||
using var client = app.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });
|
||||
var service = await ReadServiceAsync(connection);
|
||||
var fields = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/create"));
|
||||
fields["Key"] = "WithButtons";
|
||||
SetPush(fields, "uk", "Потребою поділилися", "Перегляньте деталі");
|
||||
var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key;
|
||||
var actionField = codeField[..^"LanguageCode".Length] + "ActionsJson";
|
||||
fields[actionField] = invalid ? "{\"buttons\":null}" : "{\"defaultAction\":\"openNeed\",\"buttons\":[{\"action\":\"openNeed\",\"label\":\"Переглянути\"}]}";
|
||||
var response = await client.PostAsync($"/admin/services/{service.Id}/push-templates/save", new FormUrlEncodedContent(fields));
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
var saved = await scope.ServiceProvider.GetRequiredService<IPushTemplateDefinitionRepository>().GetAsync(service.Id, "WithButtons");
|
||||
if (invalid)
|
||||
{
|
||||
Assert.Null(saved);
|
||||
Assert.Contains("validation-summary-errors", await response.Content.ReadAsStringAsync());
|
||||
Assert.Equal(fields[actionField], TemplateEditorTestFields.Read(await response.Content.ReadAsStringAsync())[actionField].Trim());
|
||||
return;
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
|
||||
var actions = PushTemplateActionValidation.Parse(Assert.Single(saved!.Languages).ActionsJson);
|
||||
Assert.Equal("openNeed", actions.DefaultAction);
|
||||
Assert.Equal("Переглянути", Assert.Single(actions.Buttons).Label);
|
||||
var reloaded = TemplateEditorTestFields.Read(await client.GetStringAsync($"/admin/services/{service.Id}/push-templates/edit?key=WithButtons"));
|
||||
var reloadedCodeField = reloaded.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == "uk").Key;
|
||||
Assert.Equal("openNeed", PushTemplateActionValidation.Parse(reloaded[reloadedCodeField[..^"LanguageCode".Length] + "ActionsJson"]).DefaultAction);
|
||||
}
|
||||
|
||||
private static async Task<NotificationServiceInfo> ReadServiceAsync(string connection)
|
||||
{
|
||||
using var provider = Provider(connection);
|
||||
using var scope = provider.CreateScope();
|
||||
return (await scope.ServiceProvider.GetRequiredService<INotificationServiceRepository>().GetByNameAsync("TestService"))!;
|
||||
}
|
||||
|
||||
private static void SetPush(Dictionary<string, string> fields, string code, string title, string body)
|
||||
{
|
||||
var codeField = fields.Single(p => p.Key.EndsWith(".LanguageCode") && p.Value == code).Key;
|
||||
var prefix = codeField[..^"LanguageCode".Length];
|
||||
fields[prefix + "Title"] = title;
|
||||
fields[prefix + "Body"] = body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.IntegrationTests;
|
||||
|
||||
public sealed class RecordingFirebasePushClient : IFirebasePushClient
|
||||
{
|
||||
public List<PushChannelTestMessage> Messages { get; } = [];
|
||||
|
||||
public Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
|
||||
{
|
||||
Messages.Add(message);
|
||||
return Task.FromResult(new PushDeliveryOutcome(null));
|
||||
}
|
||||
|
||||
public Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new PushDeliveryOutcome(null));
|
||||
|
||||
public Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -34,7 +34,7 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExplicitCleanupDeletesDefinitionsThenServiceAndLanguages_ButRetainsChannels()
|
||||
public async Task ExplicitCleanupRequiresReassigningChannelsBeforeDeletingService()
|
||||
{
|
||||
await using var context = await DefinitionApiTestContext.CreateAsync(fixture);
|
||||
await SeedChannelAsync(context);
|
||||
@@ -53,10 +53,21 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
|
||||
Assert.Equal(service.GetProperty("revision").GetGuid(), afterDefinitionDeletion.GetProperty("revision").GetGuid());
|
||||
Assert.Equal(service.GetProperty("supportedLanguageCodes").GetRawText(), afterDefinitionDeletion.GetProperty("supportedLanguageCodes").GetRawText());
|
||||
Assert.All(afterDefinitionDeletion.GetProperty("languages").EnumerateArray(), l => Assert.Equal(0, l.GetProperty("versionCount").GetInt32()));
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
using (var reassignment = context.App.Services.CreateScope())
|
||||
{
|
||||
var other = new NotificationServiceInfo { Name = "OtherService" };
|
||||
await reassignment.ServiceProvider.GetRequiredService<INotificationServiceRepository>().SaveAsync(other, true);
|
||||
var channels = reassignment.ServiceProvider.GetRequiredService<IEmailChannelRepository>();
|
||||
var channel = Assert.Single(await channels.GetAllAsync());
|
||||
channel.ServiceId = other.Id;
|
||||
await channels.UpdateAsync(channel);
|
||||
await reassignment.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
|
||||
}
|
||||
Assert.Equal(HttpStatusCode.OK, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.GetAsync(context.ServiceRoot)).StatusCode);
|
||||
var all = await context.Client.GetFromJsonAsync<JsonElement>("/api/v1/services/all");
|
||||
Assert.Empty(all.GetProperty("data").EnumerateArray());
|
||||
Assert.Single(all.GetProperty("data").EnumerateArray());
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
Assert.Single(await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().GetAllAsync());
|
||||
Assert.Equal(HttpStatusCode.NotFound, (await context.Client.DeleteAsync(context.ServiceRoot + "?revision=" + service.GetProperty("revision").GetGuid())).StatusCode);
|
||||
@@ -145,7 +156,7 @@ public sealed class ServiceDeletionApiTests(TemplateDatabaseFixture fixture) : I
|
||||
using var scope = context.App.Services.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<IEmailChannelRepository>().AddAsync(new EmailChannel
|
||||
{
|
||||
ServiceName = "TestService", EmailChannelType = EmailChannelType.Smtp,
|
||||
ServiceId = context.ServiceId, EmailChannelType = EmailChannelType.Smtp,
|
||||
Settings = new SmtpChannelSettings { Host = "localhost", FromEmail = "test@example.invalid" }
|
||||
});
|
||||
await scope.ServiceProvider.GetRequiredService<NotificationDbContext>().SaveChangesAsync();
|
||||
|
||||
@@ -116,7 +116,7 @@ public sealed class TemplateCatalogPresentationTests(TemplateDatabaseFixture fix
|
||||
var empty = await client.GetStringAsync(baseUrl);
|
||||
Assert.Contains("No languages configured for this service.", empty);
|
||||
Assert.Contains("template-key-filter", empty);
|
||||
Assert.Contains("No email templates for this service yet.", empty);
|
||||
Assert.Contains("No templates for this service yet.", empty);
|
||||
await scope.ServiceProvider.GetRequiredService<IEmailTemplateDefinitionRepository>()
|
||||
.SaveDefinitionAsync(new EmailTemplateDefinition { ServiceName = service.Name, Key = "Empty" }, true);
|
||||
var withDefinition = await client.GetStringAsync(baseUrl);
|
||||
|
||||
@@ -14,7 +14,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
[Route("admin/channels")]
|
||||
public class AdminChannelsController(IMediator mediator) : Controller
|
||||
public class AdminChannelsController(IMediator mediator, INotificationServiceRepository services) : Controller
|
||||
{
|
||||
|
||||
// GET /admin/channels
|
||||
@@ -33,10 +33,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
|
||||
// GET /admin/channels/create
|
||||
[HttpGet("create")]
|
||||
public IActionResult Create()
|
||||
{
|
||||
return View("Edit", new EmailChannelEditViewModel());
|
||||
}
|
||||
public Task<IActionResult> Create(CancellationToken ct) => EditorViewAsync(new EmailChannelEditViewModel(), ct);
|
||||
|
||||
// GET /admin/channels/{id}
|
||||
[HttpGet("{id:guid}")]
|
||||
@@ -55,7 +52,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
var vm = new EmailChannelEditViewModel
|
||||
{
|
||||
Id = channel.Id,
|
||||
ServiceName = channel.ServiceName,
|
||||
ServiceId = channel.ServiceId,
|
||||
Priority = channel.Priority,
|
||||
EmailChannelType = channel.EmailChannelType,
|
||||
DailyLimit = channel.DailyLimit,
|
||||
@@ -70,7 +67,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
FromName = smtp.FromName
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
return await EditorViewAsync(vm, ct);
|
||||
}
|
||||
|
||||
// POST /admin/channels/save
|
||||
@@ -79,13 +76,13 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
public async Task<IActionResult> Save(EmailChannelEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return EditWithErrors(model);
|
||||
return await EditorViewAsync(model, ct);
|
||||
|
||||
var (password, passwordError) = await ResolvePasswordForSave(model, ct);
|
||||
if (passwordError is not null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Password), passwordError);
|
||||
return EditWithErrors(model);
|
||||
return await EditorViewAsync(model, ct);
|
||||
}
|
||||
|
||||
var smtpSettings = new SmtpChannelSettings
|
||||
@@ -102,7 +99,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
if (model.IsNew)
|
||||
{
|
||||
var command = new CreateEmailChannelCommand(
|
||||
model.ServiceName,
|
||||
model.ServiceId!.Value,
|
||||
model.Priority,
|
||||
EmailChannelType.Smtp,
|
||||
smtpSettings,
|
||||
@@ -115,13 +112,14 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
|
||||
return EditWithErrors(model);
|
||||
return await EditorViewAsync(model, ct);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var command = new UpdateEmailChannelCommand(
|
||||
model.Id,
|
||||
model.ServiceId!.Value,
|
||||
model.Priority,
|
||||
smtpSettings,
|
||||
model.DailyLimit,
|
||||
@@ -133,7 +131,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
|
||||
return EditWithErrors(model);
|
||||
return await EditorViewAsync(model, ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +155,12 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
return (saved.Password, null);
|
||||
}
|
||||
|
||||
private IActionResult EditWithErrors(EmailChannelEditViewModel model)
|
||||
private async Task<IActionResult> EditorViewAsync(EmailChannelEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
model.Password = null;
|
||||
model.Services = (await services.GetAllAsync(ct))
|
||||
.Select(service => new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem(service.Name, service.Id.ToString()))
|
||||
.ToList();
|
||||
return View("Edit", model);
|
||||
}
|
||||
|
||||
@@ -227,5 +228,3 @@ public class AdminChannelsController(IMediator mediator) : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
|
||||
public record TestChannelRequest(string ToEmail);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.Services.PushChannels;
|
||||
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
[Route("admin/push-channels")]
|
||||
public sealed class AdminPushChannelsController(
|
||||
IPushChannelRepository channels, INotificationServiceRepository services, IMediator mediator,
|
||||
PushChannelTestService tester) : Controller
|
||||
{
|
||||
[HttpPost("test-authorization")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequestSizeLimit(131072)]
|
||||
public async Task<IActionResult> TestAuthorization([FromBody] PushChannelTestSettings? settings, CancellationToken ct)
|
||||
{
|
||||
if (settings is null || !ModelState.IsValid) return BadRequest(new { success = false, message = "Invalid test settings." });
|
||||
var result = await tester.TestAsync(settings, false, ct);
|
||||
return Json(new { success = result.Error is null, message = result.Error ??
|
||||
"OAuth access token obtained. FCM send permission and device delivery have not been verified. No push was sent." });
|
||||
}
|
||||
|
||||
[HttpPost("test-send")]
|
||||
[ValidateAntiForgeryToken]
|
||||
[RequestSizeLimit(131072)]
|
||||
public async Task<IActionResult> TestSend([FromBody] PushChannelTestSettings? settings, CancellationToken ct)
|
||||
{
|
||||
if (settings is null || !ModelState.IsValid) return BadRequest(new { success = false, message = "Invalid test settings." });
|
||||
var result = await tester.TestAsync(settings, true, ct);
|
||||
return Json(new { success = result.Error is null, message = result.Error ??
|
||||
"Firebase accepted the test message. This does not confirm delivery or display on the device." });
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Index(CancellationToken ct) => View(await channels.GetAllAsync(ct));
|
||||
|
||||
[HttpGet("create")]
|
||||
public Task<IActionResult> Create(CancellationToken ct) => EditorViewAsync(new PushChannelEditViewModel(), ct);
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> Edit(Guid id, CancellationToken ct)
|
||||
{
|
||||
var channel = await channels.GetByIdAsync(id, ct);
|
||||
if (channel is null) return NotFound();
|
||||
return await EditorViewAsync(new PushChannelEditViewModel
|
||||
{
|
||||
Id = channel.Id, ServiceId = channel.ServiceId,
|
||||
FirebaseProjectId = channel.FirebaseProjectId, IsActive = channel.IsActive
|
||||
}, ct);
|
||||
}
|
||||
|
||||
[HttpPost("save")]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Save(PushChannelEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
if (!ModelState.IsValid) return await EditorViewAsync(model, ct);
|
||||
var result = await mediator.Send(new SavePushChannelCommand
|
||||
{
|
||||
Id = model.Id, ServiceId = model.ServiceId!.Value, FirebaseProjectId = model.FirebaseProjectId,
|
||||
CredentialsJson = model.CredentialsJson, IsActive = model.IsActive
|
||||
}, ct);
|
||||
if (result.IsSuccess) return RedirectToAction(nameof(Index));
|
||||
ModelState.AddModelError(string.Empty, result.Error?.Message ?? "Could not save the push channel.");
|
||||
return await EditorViewAsync(model, ct);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> EditorViewAsync(PushChannelEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
model.CredentialsJson = null;
|
||||
// Razor tag helpers prefer ModelState over the model, so clear the attempted secret too.
|
||||
ModelState.Remove(nameof(model.CredentialsJson));
|
||||
model.Services = (await services.GetAllAsync(ct))
|
||||
.Select(s => new SelectListItem(s.Name, s.Id.ToString())).ToList();
|
||||
return View("Edit", model);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
[Route("admin/services/{serviceId:guid}/push-templates")]
|
||||
public sealed class AdminPushTemplatesController(IPushTemplateDefinitionRepository pushes,
|
||||
IEmailTemplateDefinitionRepository emails, INotificationServiceRepository services,
|
||||
ILogger<AdminPushTemplatesController> logger) : Controller
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
|
||||
[HttpGet("email")]
|
||||
public async Task<IActionResult> Email([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
if (service is null) return NotFound();
|
||||
if (string.IsNullOrEmpty(key))
|
||||
return RedirectToAction("Create", "AdminTemplates", new { serviceId, keyFilter });
|
||||
var email = await emails.GetDefinitionAsync(serviceId, key, ct);
|
||||
if (email is not null)
|
||||
return RedirectToAction("Edit", "AdminTemplates", new { serviceId, definitionId = email.Id, keyFilter });
|
||||
return Missing(service, key, keyFilter, false);
|
||||
}
|
||||
|
||||
[HttpGet("edit")]
|
||||
public async Task<IActionResult> Edit([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
if (service is null) return NotFound();
|
||||
if (string.IsNullOrEmpty(key))
|
||||
return RedirectToAction(nameof(Create), new { serviceId, keyFilter });
|
||||
var definition = await pushes.GetAsync(serviceId, key, ct);
|
||||
return definition is null ? Missing(service, key, keyFilter, true)
|
||||
: Editor(ToModel(definition, service, keyFilter), service);
|
||||
}
|
||||
|
||||
[HttpGet("create")]
|
||||
public async Task<IActionResult> Create([FromRoute] Guid serviceId, string? key, string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
if (service is null) return NotFound();
|
||||
if (key is not null && await pushes.GetAsync(serviceId, key, ct) is not null)
|
||||
return RedirectToAction(nameof(Edit), new { serviceId, key, keyFilter });
|
||||
return Editor(new PushTemplateEditViewModel
|
||||
{
|
||||
ServiceId = serviceId, ServiceName = service.Name, Key = key ?? "", KeyFilter = keyFilter,
|
||||
LockKey = key is not null && await emails.GetDefinitionAsync(serviceId, key, ct) is not null
|
||||
}, service);
|
||||
}
|
||||
|
||||
[HttpPost("save"), ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Save([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
if (service is null || serviceId != model.ServiceId) return NotFound();
|
||||
model.ServiceName = service.Name;
|
||||
var persisted = await pushes.GetAsync(serviceId, model.Key, ct);
|
||||
if (model.Id is not null && persisted?.Id != model.Id) return NotFound();
|
||||
model.LockKey = model.Id is not null || await emails.GetDefinitionAsync(serviceId, model.Key, ct) is not null;
|
||||
SetSavedStatuses(model, persisted);
|
||||
ValidateCatalog(model, service);
|
||||
if (!ModelState.IsValid) return Editor(model, service);
|
||||
return await SaveModelAsync(model, service, ct);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> SaveModelAsync(PushTemplateEditViewModel model, NotificationServiceInfo service, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pushes.SaveAsync(new PushTemplateDefinition
|
||||
{
|
||||
Id = model.Id ?? Guid.Empty, ServiceId = service.Id, Key = model.Key, Revision = model.Revision,
|
||||
Variables = EmailTemplateValidation.ParseVariables(model.VariablesJson),
|
||||
Languages = model.Languages.Where(l => l.HasContent).Select(l => new PushTemplateTranslation
|
||||
{
|
||||
LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson
|
||||
}).ToList()
|
||||
}, model.IsNew, ct);
|
||||
return RedirectToAction("Index", "AdminTemplates", new { serviceId = service.Id, key = model.KeyFilter });
|
||||
}
|
||||
catch (JsonException) { ModelState.AddModelError("VariablesJson", "Invalid variables JSON. Use name and required properties."); }
|
||||
catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); }
|
||||
catch (Exception error) when (error is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(error, "Failed to save push template definition");
|
||||
ModelState.AddModelError("", "Could not save the push template. Your entered values are retained; try again.");
|
||||
}
|
||||
return Editor(model, service);
|
||||
}
|
||||
|
||||
[HttpGet("delete")]
|
||||
public async Task<IActionResult> ConfirmDelete([FromRoute] Guid serviceId, string key, string? keyFilter, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
var definition = await pushes.GetAsync(serviceId, key, ct);
|
||||
return service is null || definition is null ? NotFound() : View("Delete", ToModel(definition, service, keyFilter));
|
||||
}
|
||||
|
||||
[HttpPost("delete"), ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete([FromRoute] Guid serviceId, PushTemplateEditViewModel model, CancellationToken ct)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
var definition = await pushes.GetAsync(serviceId, model.Key, ct);
|
||||
if (service is null || definition is null) return NotFound();
|
||||
if (!model.ConfirmDelete || !ModelState.IsValid)
|
||||
ModelState.AddModelError("", "Confirm deletion of this push template to continue.");
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
await pushes.DeleteAsync(serviceId, model.Key, model.Revision, ct);
|
||||
return RedirectToAction("Index", "AdminTemplates", new { serviceId, key = model.KeyFilter });
|
||||
}
|
||||
catch (EmailTemplateWriteException error) { ModelState.AddModelError("", error.Message); }
|
||||
}
|
||||
var errors = ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage).ToList();
|
||||
ModelState.Clear();
|
||||
foreach (var error in errors) ModelState.AddModelError("", error);
|
||||
return View("Delete", ToModel(definition, service, model.KeyFilter));
|
||||
}
|
||||
|
||||
private IActionResult Missing(NotificationServiceInfo service, string key, string? keyFilter, bool isPush) => View("Missing", new TemplateChannelNavigationViewModel
|
||||
{
|
||||
ServiceId = service.Id, ServiceName = service.Name, Key = key, KeyFilter = keyFilter, IsPush = isPush
|
||||
});
|
||||
|
||||
private IActionResult Editor(PushTemplateEditViewModel model, NotificationServiceInfo service)
|
||||
{
|
||||
foreach (var language in service.Languages)
|
||||
if (!model.Languages.Any(l => l.LanguageCode == language.Code))
|
||||
model.Languages.Add(new PushTemplateLanguageEditViewModel { LanguageCode = language.Code });
|
||||
return View("Edit", model);
|
||||
}
|
||||
|
||||
private void ValidateCatalog(PushTemplateEditViewModel model, NotificationServiceInfo service)
|
||||
{
|
||||
var codes = model.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||
if (codes.Count != model.Languages.Count || !codes.SetEquals(service.Languages.Select(l => l.Code)))
|
||||
ModelState.AddModelError("", "The service language catalog changed or the submitted tabs are invalid. Reload before saving.");
|
||||
}
|
||||
|
||||
private static void SetSavedStatuses(PushTemplateEditViewModel model, PushTemplateDefinition? persisted)
|
||||
{
|
||||
var codes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal) ?? [];
|
||||
foreach (var language in model.Languages) language.IsPresent = codes.Contains(language.LanguageCode);
|
||||
}
|
||||
|
||||
private static PushTemplateEditViewModel ToModel(PushTemplateDefinition definition, NotificationServiceInfo service, string? keyFilter) => new()
|
||||
{
|
||||
Id = definition.Id, ServiceId = service.Id, ServiceName = service.Name, Key = definition.Key,
|
||||
Revision = definition.Revision, KeyFilter = keyFilter, LockKey = true,
|
||||
VariablesJson = JsonSerializer.Serialize(definition.Variables, JsonOptions),
|
||||
Languages = definition.Languages.Select(l => new PushTemplateLanguageEditViewModel
|
||||
{
|
||||
LanguageCode = l.LanguageCode, Title = l.Title, Body = l.Body, ActionsJson = l.ActionsJson, IsPresent = true
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
@@ -12,7 +12,7 @@ namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
[Route("admin/services/{serviceId:guid}/templates")]
|
||||
public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateDefinitionRepository definitions,
|
||||
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services) : Controller
|
||||
ILogger<AdminTemplatesController> logger, INotificationServiceRepository services, IPushTemplateDefinitionRepository pushes) : Controller
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
|
||||
@@ -27,6 +27,8 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
||||
ModelState.AddModelError("", previousError);
|
||||
try
|
||||
{
|
||||
model.PushDefinitions = (await pushes.GetDefinitionsAsync(serviceId, ct))
|
||||
.Where(d => string.IsNullOrWhiteSpace(key) || d.Key == key).ToList();
|
||||
model.Definitions = (await definitions.GetDefinitionsAsync(service.Name, key, ct))
|
||||
.Where(d => d.ServiceId == serviceId).ToList();
|
||||
}
|
||||
@@ -39,12 +41,12 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
||||
}
|
||||
|
||||
[HttpGet("create")]
|
||||
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct)
|
||||
public async Task<IActionResult> Create([FromRoute] Guid serviceId, [FromQuery] string? keyFilter, CancellationToken ct, [FromQuery] string? templateKey = null)
|
||||
{
|
||||
var service = await services.GetAsync(serviceId, ct);
|
||||
return service is null ? NotFound() : EditorView(new EmailTemplateEditViewModel
|
||||
{
|
||||
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter
|
||||
ServiceId = service.Id, ServiceName = service.Name, KeyFilter = keyFilter, Key = templateKey ?? "", LockKey = !string.IsNullOrEmpty(templateKey)
|
||||
}, service);
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ public sealed class AdminTemplatesController(IMediator mediator, IEmailTemplateD
|
||||
var persisted = model.Id is Guid definitionId ? await OwnedDefinitionAsync(service, definitionId, ct) : null;
|
||||
if (model.Id is not null && persisted is null)
|
||||
return NotFound();
|
||||
model.LockKey = model.Id is not null || await pushes.GetAsync(serviceId, model.Key, ct) is not null;
|
||||
var presentCodes = persisted?.Languages.Select(l => l.LanguageCode).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var language in model.Languages)
|
||||
language.IsPresent = presentCodes?.Contains(language.LanguageCode) == true;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace HrynCo.NotificationService.Web.Controllers.Admin;
|
||||
|
||||
public sealed record TestChannelRequest(string ToEmail);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user