First Commit

This commit is contained in:
Jorge Luis caudillo esparza
2026-05-09 16:36:57 -07:00
parent 96ad4c0d3e
commit d0c284d9c0
131 changed files with 4009 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
<ProjectReference Include="..\ADS.Helpers.Localization\ADS.Helpers.Localization.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.15.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
using ADS.Helpers.NotifyCenter.Models;
namespace ADS.Helpers.NotifyCenter.Abstractions;
public interface INotificationProvider
{
NotificationChannel Channel { get; }
string Name { get; }
Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
namespace ADS.Helpers.NotifyCenter.Abstractions;
public interface INotificationSettingsProvider
{
Task<TSettings> GetAsync<TSettings>(string tenantId, CancellationToken ct = default)
where TSettings : class, new();
}

View File

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
using ADS.Helpers.NotifyCenter.Models;
namespace ADS.Helpers.NotifyCenter.Abstractions;
public interface INotifyCenter
{
Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default);
}

View File

@@ -0,0 +1,34 @@
using ADS.Helpers.NotifyCenter.Abstractions;
using ADS.Helpers.NotifyCenter.Models;
using ADS.Helpers.NotifyCenter.Providers;
using ADS.Helpers.NotifyCenter.Providers.Smtp;
using Microsoft.Extensions.DependencyInjection;
namespace ADS.Helpers.NotifyCenter.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAdsNotifyCenter(this IServiceCollection services)
{
services.AddOptions<NotifyCenterOptions>();
services.AddSingleton<INotifyCenter, NotifyCenter>();
// Default settings provider (replace later with tenant-aware provider)
services.AddSingleton<InMemoryNotificationSettingsProvider>();
services.AddSingleton<INotificationSettingsProvider>(sp => sp.GetRequiredService<InMemoryNotificationSettingsProvider>());
// Providers
services.AddSingleton<INotificationProvider, SmtpNotificationProvider>();
return services;
}
// Helper to configure SMTP quickly when using InMemoryNotificationSettingsProvider
public static IServiceCollection ConfigureAdsSmtp(this IServiceCollection services, SmtpOptions options, string? tenantId = null)
{
var sp = services.BuildServiceProvider();
var mem = sp.GetRequiredService<InMemoryNotificationSettingsProvider>();
mem.Set(options, tenantId);
return services;
}
}

View File

@@ -0,0 +1,7 @@
namespace ADS.Helpers.NotifyCenter.Models;
public static class MessageSources
{
public const string NotifyCenter = "ADS.NotifyCenter";
public const string SmtpProvider = "ADS.NotifyCenter.Smtp";
}

View File

@@ -0,0 +1,13 @@
namespace ADS.Helpers.NotifyCenter.Models;
public static class NotifyCodes
{
public const string Ok = "NOTIFY_OK";
public const string NoProvider = "NOTIFY_NO_PROVIDER";
public const string InvalidMessage = "NOTIFY_INVALID_MESSAGE";
public const string SmtpNotConfigured = "SMTP_NOT_CONFIGURED";
public const string SmtpSendFailed = "SMTP_SEND_FAILED";
public const string TenantContextMissing = "TENANT_CONTEXT_MISSING";
}

View File

@@ -0,0 +1,11 @@
namespace ADS.Helpers.NotifyCenter.Models;
public enum NotificationChannel
{
SmtpEmail = 1,
SignalR = 2,
Sms = 3,
WhatsApp = 4,
Webhook = 5,
Push = 6
}

View File

@@ -0,0 +1,26 @@
namespace ADS.Helpers.NotifyCenter.Models;
public sealed class NotificationMessage
{
public required NotificationChannel Channel { get; init; }
// Generic recipients. Providers can interpret these as they need.
// For SMTP: To/Cc/Bcc are emails.
public List<string> To { get; init; } = new();
public List<string> Cc { get; init; } = new();
public List<string> Bcc { get; init; } = new();
public string? Subject { get; init; }
public string? BodyText { get; init; }
public string? BodyHtml { get; init; }
// Optional provider-specific metadata (e.g. phone numbers formatting, template ids, etc.)
public Dictionary<string, string> Metadata { get; init; } = new();
// Optional correlation id from caller
public string? TraceId { get; init; }
public string? Culture { get; init; }
}

View File

@@ -0,0 +1,57 @@
namespace ADS.Helpers.NotifyCenter.Models;
public sealed class NotificationResult
{
public bool Success { get; init; }
public string Source { get; init; } = ""; // quien emitió
public string Code { get; init; } = ""; // keyword estable
public string? Message { get; init; } // ya traducido (si se resolvió)
public string? DebugMessage { get; init; }
public string? ProviderName { get; init; }
public string? ProviderMessageId { get; init; }
public static NotificationResult Ok(
string providerName,
string source,
string code,
string? providerMessageId = null,
string? debugMessage = null)
=> new()
{
Success = true,
ProviderName = providerName,
Source = source,
Code = code,
ProviderMessageId = providerMessageId,
DebugMessage = debugMessage
};
public static NotificationResult Fail(
string providerName,
string source,
string code,
string? debugMessage = null)
=> new()
{
Success = false,
ProviderName = providerName,
Source = source,
Code = code,
DebugMessage = debugMessage
};
public NotificationResult WithMessage(string message)
=> new()
{
Success = Success,
ProviderName = ProviderName,
ProviderMessageId = ProviderMessageId,
Source = Source,
Code = Code,
DebugMessage = DebugMessage,
Message = message
};
}

View File

@@ -0,0 +1,10 @@
namespace ADS.Helpers.NotifyCenter.Models;
public sealed class NotifyCenterOptions
{
// If true, NotifyCenter will throw when no provider is registered for the requested channel.
public bool ThrowIfNoProvider { get; set; } = true;
// If true, NotifyCenter will validate basic fields and fail fast.
public bool ValidateMessage { get; set; } = true;
}

View File

@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ADS.Helpers.Base.Abstractions;
using ADS.Helpers.Localization.Abstractions;
using ADS.Helpers.MultiTenant.Context;
using ADS.Helpers.NotifyCenter.Abstractions;
using ADS.Helpers.NotifyCenter.Models;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.NotifyCenter;
public sealed class NotifyCenter : INotifyCenter
{
private readonly IReadOnlyList<INotificationProvider> _providers;
private readonly NotifyCenterOptions _options;
private readonly IMessageCatalog _catalog;
private readonly IAppLogger _log;
private readonly ITenantContextAccessor _tenantAccessor;
private const string DefaultCulture = "es-MX";
public NotifyCenter(
IEnumerable<INotificationProvider> providers,
IOptions<NotifyCenterOptions> options,
IMessageCatalog catalog,
IAppLogger log,
ITenantContextAccessor tenantAccessor)
{
_providers = providers.ToList();
_options = options.Value;
_catalog = catalog;
_log = log;
_tenantAccessor = tenantAccessor;
}
public async Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default)
{
var tenant = _tenantAccessor.Current;
if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId))
{
var dbg = "TenantContext is not available. Ensure TenantMiddleware runs before using NotifyCenter.";
_log.Error(
source: MessageSources.NotifyCenter,
code: NotifyCodes.TenantContextMissing,
message: dbg,
ex: null,
data: new { message.TraceId });
var fail = NotificationResult.Fail(
providerName: "NotifyCenter",
source: MessageSources.NotifyCenter,
code: NotifyCodes.TenantContextMissing,
debugMessage: dbg);
return Localize(fail, DefaultCulture);
}
var culture = string.IsNullOrWhiteSpace(tenant.Culture) ? DefaultCulture : tenant.Culture;
if (_options.ValidateMessage)
{
var validation = Validate(message);
if (!validation.Success)
return Localize(validation, culture);
}
var provider = _providers.FirstOrDefault(p => p.Channel == message.Channel);
if (provider is null)
{
var debug = $"No notification provider registered for channel '{message.Channel}'.";
if (_options.ThrowIfNoProvider)
{
_log.Error(
source: MessageSources.NotifyCenter,
code: NotifyCodes.NoProvider,
message: debug,
ex: null,
data: new { message.TraceId, tenant.TenantId });
throw new InvalidOperationException(debug);
}
_log.Warn(
source: MessageSources.NotifyCenter,
code: NotifyCodes.NoProvider,
message: debug,
data: new { message.TraceId, tenant.TenantId });
var fail = NotificationResult.Fail(
providerName: "NotifyCenter",
source: MessageSources.NotifyCenter,
code: NotifyCodes.NoProvider,
debugMessage: debug);
return Localize(fail, culture);
}
// Important: providers can still use tenant via TenantContextAccessor if needed,
// or you can pass tenant id through message.Metadata.
// Here we pass it so providers that are settings-driven can resolve settings by tenant.
message.Metadata["tenantId"] = tenant.TenantId;
var result = await provider.SendAsync(message, ct);
if (!result.Success)
{
_log.Error(
source: result.Source,
code: result.Code,
message: "Notification provider failed.",
ex: null,
data: new
{
message.TraceId,
tenant.TenantId,
result.ProviderName,
result.DebugMessage
});
}
return Localize(result, culture);
}
private NotificationResult Localize(NotificationResult result, string culture)
{
var localizedMessage = _catalog.Get(
source: result.Source,
code: result.Code,
culture: culture);
return result.WithMessage(localizedMessage);
}
private static NotificationResult Validate(NotificationMessage message)
{
if (message.To.Count == 0 && message.Cc.Count == 0 && message.Bcc.Count == 0)
{
return NotificationResult.Fail(
providerName: "NotifyCenter",
source: MessageSources.NotifyCenter,
code: NotifyCodes.InvalidMessage,
debugMessage: "To/Cc/Bcc are all empty.");
}
if (string.IsNullOrWhiteSpace(message.Subject) &&
message.Channel == NotificationChannel.SmtpEmail)
{
return NotificationResult.Fail(
providerName: "NotifyCenter",
source: MessageSources.NotifyCenter,
code: NotifyCodes.InvalidMessage,
debugMessage: "Subject is required for SMTP.");
}
if (string.IsNullOrWhiteSpace(message.BodyText) &&
string.IsNullOrWhiteSpace(message.BodyHtml))
{
return NotificationResult.Fail(
providerName: "NotifyCenter",
source: MessageSources.NotifyCenter,
code: NotifyCodes.InvalidMessage,
debugMessage: "BodyText and BodyHtml are empty.");
}
return NotificationResult.Ok(
providerName: "NotifyCenter",
source: MessageSources.NotifyCenter,
code: NotifyCodes.Ok,
providerMessageId: null,
debugMessage: "Validation passed.");
}
}

View File

@@ -0,0 +1,33 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using ADS.Helpers.NotifyCenter.Abstractions;
namespace ADS.Helpers.NotifyCenter.Providers;
// Simple in-memory settings store. Later you replace this with a tenant-aware provider (DB/SQLite/etc.)
public sealed class InMemoryNotificationSettingsProvider : INotificationSettingsProvider
{
private readonly ConcurrentDictionary<(Type type, string? tenantId), object> _store = new();
public Task<TSettings> GetAsync<TSettings>(string? tenantId, CancellationToken ct = default)
where TSettings : class, new()
{
var key = (typeof(TSettings), tenantId);
if (_store.TryGetValue(key, out var obj) && obj is TSettings typed)
return Task.FromResult(typed);
// fallback to default (tenantId = null)
var keyDefault = (typeof(TSettings), (string?)null);
if (_store.TryGetValue(keyDefault, out var objDefault) && objDefault is TSettings typedDefault)
return Task.FromResult(typedDefault);
return Task.FromResult(new TSettings());
}
public void Set<TSettings>(TSettings settings, string? tenantId = null) where TSettings : class
{
_store[(typeof(TSettings), tenantId)] = settings;
}
}

View File

@@ -0,0 +1,139 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ADS.Helpers.Base.Abstractions;
using ADS.Helpers.MultiTenant.Context;
using ADS.Helpers.NotifyCenter.Abstractions;
using ADS.Helpers.NotifyCenter.Models;
using MailKit.Net.Smtp;
using MailKit.Security;
namespace ADS.Helpers.NotifyCenter.Providers.Smtp;
public sealed class SmtpNotificationProvider : INotificationProvider
{
private readonly INotificationSettingsProvider _settingsProvider;
private readonly ITenantContextAccessor _tenantAccessor;
private readonly IAppLogger _log;
public NotificationChannel Channel => NotificationChannel.SmtpEmail;
public string Name => "SMTP";
public SmtpNotificationProvider(
INotificationSettingsProvider settingsProvider,
ITenantContextAccessor tenantAccessor,
IAppLogger log)
{
_settingsProvider = settingsProvider;
_tenantAccessor = tenantAccessor;
_log = log;
}
public async Task<NotificationResult> SendAsync(NotificationMessage message, CancellationToken ct = default)
{
var tenant = _tenantAccessor.Current;
if (tenant is null || string.IsNullOrWhiteSpace(tenant.TenantId))
{
var dbg = "TenantContext is not available for SMTP provider.";
_log.Error(
source: MessageSources.SmtpProvider,
code: NotifyCodes.TenantContextMissing,
message: dbg,
ex: null,
data: new { message.TraceId });
return NotificationResult.Fail(
providerName: Name,
source: MessageSources.SmtpProvider,
code: NotifyCodes.TenantContextMissing,
debugMessage: dbg);
}
var opts = await _settingsProvider.GetAsync<SmtpSettings>(tenant.TenantId, ct);
if (string.IsNullOrWhiteSpace(opts.Host) || opts.Port <= 0 || string.IsNullOrWhiteSpace(opts.FromEmail))
{
_log.Warn(
source: MessageSources.SmtpProvider,
code: NotifyCodes.SmtpNotConfigured,
message: "SMTP settings are missing or invalid.",
data: new { message.TraceId, tenant.TenantId });
return NotificationResult.Fail(
providerName: Name,
source: MessageSources.SmtpProvider,
code: NotifyCodes.SmtpNotConfigured,
debugMessage: "Host/Port/FromEmail missing or invalid.");
}
var mime = new MimeKit.MimeMessage();
mime.From.Add(new MimeKit.MailboxAddress(opts.FromName ?? "", opts.FromEmail));
foreach (var to in message.To.Distinct(StringComparer.OrdinalIgnoreCase))
mime.To.Add(MimeKit.MailboxAddress.Parse(to));
foreach (var cc in message.Cc.Distinct(StringComparer.OrdinalIgnoreCase))
mime.Cc.Add(MimeKit.MailboxAddress.Parse(cc));
foreach (var bcc in message.Bcc.Distinct(StringComparer.OrdinalIgnoreCase))
mime.Bcc.Add(MimeKit.MailboxAddress.Parse(bcc));
mime.Subject = message.Subject ?? "";
var bodyBuilder = new MimeKit.BodyBuilder
{
TextBody = message.BodyText,
HtmlBody = message.BodyHtml
};
mime.Body = bodyBuilder.ToMessageBody();
try
{
using var client = new SmtpClient { Timeout = opts.TimeoutMs };
SecureSocketOptions sec;
if (opts.UseSsl) sec = SecureSocketOptions.SslOnConnect;
else if (opts.UseStartTls) sec = SecureSocketOptions.StartTls;
else sec = SecureSocketOptions.None;
await client.ConnectAsync(opts.Host, opts.Port, sec, ct);
if (!string.IsNullOrWhiteSpace(opts.Username))
await client.AuthenticateAsync(opts.Username, opts.Password, ct);
await client.SendAsync(mime, ct);
await client.DisconnectAsync(true, ct);
_log.Info(
source: MessageSources.SmtpProvider,
code: NotifyCodes.Ok,
message: "SMTP message sent.",
data: new { message.TraceId, tenant.TenantId });
return NotificationResult.Ok(
providerName: Name,
source: MessageSources.SmtpProvider,
code: NotifyCodes.Ok,
providerMessageId: null,
debugMessage: "SMTP message sent.");
}
catch (Exception ex)
{
_log.Error(
source: MessageSources.SmtpProvider,
code: NotifyCodes.SmtpSendFailed,
message: "SMTP send failed.",
ex: ex,
data: new { message.TraceId, tenant.TenantId });
return NotificationResult.Fail(
providerName: Name,
source: MessageSources.SmtpProvider,
code: NotifyCodes.SmtpSendFailed,
debugMessage: ex.Message);
}
}
}

View File

@@ -0,0 +1,19 @@
namespace ADS.Helpers.NotifyCenter.Providers.Smtp;
public sealed class SmtpOptions
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = false; // implicit SSL (SMTPS)
public bool UseStartTls { get; set; } = true; // STARTTLS
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string FromEmail { get; set; } = "";
public string FromName { get; set; } = "";
// Optional timeout
public int TimeoutMs { get; set; } = 15000;
}

View File

@@ -0,0 +1,18 @@
namespace ADS.Helpers.NotifyCenter.Providers.Smtp;
public sealed class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = false;
public bool UseStartTls { get; set; } = true;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public string FromEmail { get; set; } = "";
public string FromName { get; set; } = "";
public int TimeoutMs { get; set; } = 15000;
}