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,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace ADS.Helpers.Auth.Abstractions;
public sealed class AdsClientValidationResult
{
public bool IsValid { get; set; }
public string ClientId { get; set; } = default!;
public string? TenantId { get; set; }
public List<Claim> ExtraClaims { get; set; } = new();
public string? Error { get; set; }
}
public interface IAdsClientCredentialValidator
{
Task<AdsClientValidationResult> ValidateAsync(
string clientId,
string clientSecret,
string? tenantId,
string? scope,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,9 @@
using ADS.Helpers.Auth.Models;
namespace ADS.Helpers.Auth.Abstractions;
public interface IAdsPrincipalAccessor
{
AdsPrincipal Current { get; }
bool IsAuthenticated { get; }
}

View File

@@ -0,0 +1,13 @@
using ADS.Helpers.Auth.Token;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace ADS.Helpers.Auth.Abstractions;
public interface IAdsTokenService
{
Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default);
Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default);
}

View File

@@ -0,0 +1,37 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
namespace ADS.Helpers.Auth.Abstractions;
public sealed class AdsUserValidationResult
{
public bool IsValid { get; set; }
public int CveUser { get; set; }
public string? UserName { get; set; }
public int CveRole { get; set; }
public int CveEmpresa { get; set; }
public int CveCliente { get; set; }
public int[] CveModulos { get; set; } = [];
public string[] Permissions { get; set; } = [];
public List<Claim> ExtraClaims { get; set; } = new();
public string? Error { get; set; }
public string? TenantId { get; set; }
}
public interface IAdsUserCredentialValidator
{
Task<AdsUserValidationResult> ValidateAsync(
string userName,
string password,
string? tenantId,
string? scope,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
namespace ADS.Helpers.Auth.Claims;
public static class AdsClaimTypes
{
public const string CveUser = "ads:cve_user";
public const string TenantId = "ads:tenant_id";
public const string UserName = "ads:username";
public const string CveRole = "ads:cve_role";
public const string CveEmpresa = "ads:cve_empresa";
public const string CveCliente = "ads:cve_cliente";
public const string CveModulos = "ads:cve_modulos";
public const string Permissions = "ads:permissions";
}

View File

@@ -0,0 +1,8 @@
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
public sealed class TokenRequest
{
public string ClientId { get; set; } = default!;
public string ClientSecret { get; set; } = default!;
public string? Scope { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace ADS.Helpers.Auth.Contracts.Sicrep.Auth;
public sealed class TokenResponse
{
public string AccessToken { get; set; } = default!;
public string TokenType { get; set; } = "Bearer";
public int ExpiresIn { get; set; }
public string? Scope { get; set; }
}

View File

@@ -0,0 +1,59 @@
using System.Text;
using ADS.Helpers.Auth.Abstractions;
using ADS.Helpers.Auth.Implementation;
using ADS.Helpers.Auth.Models;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
namespace ADS.Helpers.Auth.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAdsAuthentication(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddHttpContextAccessor();
var jwtSection = configuration.GetSection("Jwt");
services.Configure<JwtOptions>(jwtSection);
var jwtOptions = jwtSection.Get<JwtOptions>()
?? throw new InvalidOperationException("Jwt configuration missing.");
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey));
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = jwtOptions.ValidateLifetime,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtOptions.Issuer,
ValidAudience = jwtOptions.Audience,
IssuerSigningKey = key,
ClockSkew = TimeSpan.FromMinutes(jwtOptions.ClockSkewMinutes)
};
});
services.AddScoped<IAdsPrincipalAccessor, AdsPrincipalAccessor>();
// NUEVO: servicio emisor de token (requiere que el host registre validadores)
services.AddScoped<IAdsTokenService, AdsTokenService>();
return services;
}
public static IServiceCollection AddAdsAuthorization(
this IServiceCollection services)
{
services.AddAuthorization();
return services;
}
}

View File

@@ -0,0 +1,66 @@
using System.Security.Claims;
using ADS.Helpers.Auth.Abstractions;
using ADS.Helpers.Auth.Claims;
using ADS.Helpers.Auth.Models;
using Microsoft.AspNetCore.Http;
namespace ADS.Helpers.Auth.Implementation;
internal sealed class AdsPrincipalAccessor : IAdsPrincipalAccessor
{
private readonly IHttpContextAccessor _httpContextAccessor;
private AdsPrincipal? _cached;
public AdsPrincipalAccessor(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
public bool IsAuthenticated =>
_httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated == true;
public AdsPrincipal Current
{
get
{
if (_cached is not null)
return _cached;
var user = _httpContextAccessor.HttpContext?.User
?? throw new UnauthorizedAccessException("No HttpContext user.");
_cached = Map(user);
return _cached;
}
}
private static AdsPrincipal Map(ClaimsPrincipal user)
{
int ParseInt(string type) =>
int.TryParse(user.FindFirst(type)?.Value, out var val) ? val : 0;
int[] ParseIntArray(string type) =>
user.FindAll(type)
.Select(x => int.TryParse(x.Value, out var v) ? v : 0)
.Where(x => x > 0)
.ToArray();
string[] ParseStringArray(string type) =>
user.FindAll(type)
.Select(x => x.Value)
.ToArray();
return new AdsPrincipal
{
CveUser = ParseInt(AdsClaimTypes.CveUser),
TenantId = AdsClaimTypes.TenantId,
UserName = user.FindFirst(AdsClaimTypes.UserName)?.Value,
CveRole = ParseInt(AdsClaimTypes.CveRole),
CveEmpresa = ParseInt(AdsClaimTypes.CveEmpresa),
CveCliente = ParseInt(AdsClaimTypes.CveCliente),
CveModulos = ParseIntArray(AdsClaimTypes.CveModulos),
Permissions = ParseStringArray(AdsClaimTypes.Permissions),
Claims = user.Claims.ToDictionary(x => x.Type, x => x.Value)
};
}
}

View File

@@ -0,0 +1,158 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using ADS.Helpers.Auth.Abstractions;
using ADS.Helpers.Auth.Claims;
using ADS.Helpers.Auth.Models;
using ADS.Helpers.Auth.Token;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace ADS.Helpers.Auth.Implementation;
internal sealed class AdsTokenService : IAdsTokenService
{
private readonly JwtOptions _options;
private readonly IAdsUserCredentialValidator _userValidator;
private readonly IAdsClientCredentialValidator _clientValidator;
public AdsTokenService(
IOptions<JwtOptions> options,
IAdsUserCredentialValidator userValidator,
IAdsClientCredentialValidator clientValidator)
{
_options = options.Value ?? throw new InvalidOperationException("JwtOptions missing.");
_userValidator = userValidator ?? throw new ArgumentNullException(nameof(userValidator));
_clientValidator = clientValidator ?? throw new ArgumentNullException(nameof(clientValidator));
}
public async Task<AdsTokenResult> IssueAsync(AdsTokenRequest request, CancellationToken ct = default)
{
if (request is null) throw new ArgumentNullException(nameof(request));
if (string.IsNullOrWhiteSpace(request.GrantType)) throw new InvalidOperationException("GrantType is required.");
var grant = request.GrantType.Trim().ToLowerInvariant();
if (grant == AdsGrantTypes.Password)
return await IssuePasswordAsync(request, ct);
if (grant == AdsGrantTypes.ClientCredentials)
return await IssueClientCredentialsAsync(request, ct);
throw new InvalidOperationException($"Unsupported GrantType: {request.GrantType}");
}
private async Task<AdsTokenResult> IssuePasswordAsync(AdsTokenRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.UserName))
throw new InvalidOperationException("UserName is required for password grant.");
if (string.IsNullOrWhiteSpace(request.Password))
throw new InvalidOperationException("Password is required for password grant.");
var validation = await _userValidator.ValidateAsync(
request.UserName.Trim(),
request.Password,
request.TenantId,
request.Scope,
ct);
if (!validation.IsValid)
throw new UnauthorizedAccessException(validation.Error ?? "Invalid credentials.");
var claims = new List<Claim>
{
new(AdsClaimTypes.CveUser, validation.CveUser.ToString()),
new(AdsClaimTypes.UserName, validation.UserName ?? request.UserName.Trim()),
new(AdsClaimTypes.CveRole, validation.CveRole.ToString()),
new(AdsClaimTypes.CveEmpresa, validation.CveEmpresa.ToString()),
new(AdsClaimTypes.CveCliente, validation.CveCliente.ToString()),
};
if (!string.IsNullOrWhiteSpace(request.TenantId))
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
foreach (var m in validation.CveModulos.Where(x => x > 0))
claims.Add(new Claim(AdsClaimTypes.CveModulos, m.ToString()));
foreach (var p in validation.Permissions.Where(x => !string.IsNullOrWhiteSpace(x)))
claims.Add(new Claim(AdsClaimTypes.Permissions, p));
if (validation.ExtraClaims.Count > 0)
claims.AddRange(validation.ExtraClaims);
return BuildToken(claims, request.Scope);
}
private async Task<AdsTokenResult> IssueClientCredentialsAsync(AdsTokenRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.ClientId))
throw new InvalidOperationException("ClientId is required for client_credentials grant.");
if (string.IsNullOrWhiteSpace(request.ClientSecret))
throw new InvalidOperationException("ClientSecret is required for client_credentials grant.");
var validation = await _clientValidator.ValidateAsync(
request.ClientId.Trim(),
request.ClientSecret,
request.TenantId,
request.Scope,
ct);
if (!validation.IsValid)
throw new UnauthorizedAccessException(validation.Error ?? "Invalid client credentials.");
var claims = new List<Claim>
{
new(AdsClaimTypes.UserName, validation.ClientId),
};
var tenantId = validation.TenantId ?? request.TenantId;
if (!string.IsNullOrWhiteSpace(request.TenantId))
claims.Add(new Claim(AdsClaimTypes.TenantId, request.TenantId));
if (validation.ExtraClaims.Count > 0)
claims.AddRange(validation.ExtraClaims);
return BuildToken(claims, request.Scope);
}
private AdsTokenResult BuildToken(IReadOnlyCollection<Claim> claims, string? scope)
{
if (string.IsNullOrWhiteSpace(_options.SigningKey))
throw new InvalidOperationException("JwtOptions.SigningKey is required.");
var now = DateTime.UtcNow;
var expires = now.AddMinutes(_options.AccessTokenMinutes <= 0 ? 60 : _options.AccessTokenMinutes);
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.SigningKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var jwt = new JwtSecurityToken(
issuer: _options.Issuer,
audience: _options.Audience,
claims: claims,
notBefore: now,
expires: expires,
signingCredentials: creds
);
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwt);
return new AdsTokenResult
{
AccessToken = accessToken,
TokenType = "Bearer",
ExpiresIn = (int)Math.Max(0, (expires - now).TotalSeconds),
Scope = scope
};
}
public Task<AdsTokenResult> IssueFromClaimsAsync(IReadOnlyCollection<Claim> claims, string? scope = null, CancellationToken ct = default)
{
if (claims is null) throw new ArgumentNullException(nameof(claims));
// ct no se usa, pero lo dejamos por consistencia
return Task.FromResult(BuildToken(claims, scope));
}
}

View File

@@ -0,0 +1,22 @@
namespace ADS.Helpers.Auth.Models;
public sealed class AdsPrincipal
{
public int CveUser { get; init; }
public string? TenantId { get; init; }
public string? UserName { get; init; }
public int CveRole { get; init; }
public int CveEmpresa { get; init; }
public int CveCliente { get; init; }
public int[] CveModulos { get; init; } = [];
public string[] Permissions { get; init; } = [];
public Dictionary<string, string> Claims { get; init; } = new();
}

View File

@@ -0,0 +1,11 @@
namespace ADS.Helpers.Auth.Models;
public sealed class JwtOptions
{
public string Issuer { get; set; } = default!;
public string Audience { get; set; } = default!;
public string SigningKey { get; set; } = default!;
public bool ValidateLifetime { get; set; } = true;
public int ClockSkewMinutes { get; set; } = 5;
public int AccessTokenMinutes { get; set; } = 60;
}

View File

@@ -0,0 +1,26 @@
namespace ADS.Helpers.Auth.Token;
public static class AdsGrantTypes
{
public const string Password = "password";
public const string ClientCredentials = "client_credentials";
}
public sealed class AdsTokenRequest
{
public string GrantType { get; set; } = AdsGrantTypes.Password;
// password
public string? UserName { get; set; }
public string? Password { get; set; }
// client_credentials
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
// multitenant / contexto
public string? TenantId { get; set; }
// opcional: scope
public string? Scope { get; set; }
}

View File

@@ -0,0 +1,9 @@
namespace ADS.Helpers.Auth.Token;
public sealed class AdsTokenResult
{
public string AccessToken { get; set; } = default!;
public string TokenType { get; set; } = "Bearer";
public int ExpiresIn { get; set; }
public string? Scope { get; set; }
}

View File

@@ -0,0 +1,44 @@
9️⃣ appsettings.json ejemplo
"Jwt": {
"Issuer": "ADS",
"Audience": "ADS_APIS",
"SigningKey": "SUPER_SECRET_KEY_CHANGE_THIS",
"ValidateLifetime": true,
"ClockSkewMinutes": 5
}
🔟 Cómo usarlo en el Host o API GPS
builder.Services.AddAdsAuthentication(builder.Configuration);
builder.Services.AddAdsAuthorization();
app.UseAuthentication();
app.UseAuthorization();
Y en cualquier controlador:
public class GpsController : ControllerBase
{
private readonly IAdsPrincipalAccessor _principal;
public GpsController(IAdsPrincipalAccessor principal)
{
_principal = principal;
}
[HttpGet]
public IActionResult Get()
{
var user = _principal.Current;
return Ok(new
{
user.CveUser,
user.TenantId,
user.CveEmpresa,
user.CveModulos
});
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
namespace ADS.Helpers.Base.Abstractions;
public interface IAppLogger
{
void Info(string source, string code, string message, object? data = null);
void Warn(string source, string code, string message, object? data = null);
void Error(string source, string code, string message, Exception? ex = null, object? data = null);
}

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Base;
public class Class1
{
}

View File

@@ -0,0 +1,9 @@
✅ 4️⃣ En tu API (Host)
En Program.cs:
builder.Services.AddAdsLogging();
builder.Services.AddAdsNotifyCenter();
Y listo.

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
using System.Data;
namespace ADS.Helpers.Data.Abstractions;
public interface ITenantDbConnectionFactory
{
IDbConnection Create();
}

View File

@@ -0,0 +1,13 @@
using ADS.Helpers.Data.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace ADS.Helpers.Data.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAdsTenantDb(this IServiceCollection services)
{
services.AddScoped<ITenantDbConnectionFactory, TenantDbConnectionFactory>();
return services;
}
}

View File

@@ -0,0 +1,26 @@
using System.Data;
using ADS.Helpers.Data.Abstractions;
using ADS.Helpers.MultiTenant.Context; // o .Abstractions (según donde esté tu interfaz)
using Microsoft.Data.SqlClient;
namespace ADS.Helpers.Data;
public sealed class TenantDbConnectionFactory : ITenantDbConnectionFactory
{
private readonly ITenantContextAccessor _tenantAccessor;
public TenantDbConnectionFactory(ITenantContextAccessor tenantAccessor)
=> _tenantAccessor = tenantAccessor;
public IDbConnection Create()
{
var tenant = _tenantAccessor.Current
?? throw new InvalidOperationException("TENANT_NOT_RESOLVED");
var cs = tenant.Database?.ConnectionString;
if (string.IsNullOrWhiteSpace(cs))
throw new InvalidOperationException("TENANT_CONNECTION_STRING_MISSING");
return new SqlConnection(cs);
}
}

View File

@@ -0,0 +1,15 @@
7) Usarlo en cualquier micro (GPS incluido)
En el Program.cs del micro:
using ADS.Helpers.Data.Extensions;
builder.Services.AddAdsMiddlewares(builder.Configuration); // tu pipeline tenant
builder.Services.AddAdsTenantDb();
Y en repositorios (Dapper):
using ADS.Helpers.Data.Abstractions;
using var cn = _dbFactory.Create();

View File

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

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Localization.Abstractions;
public interface IMessageCatalog
{
string Get(string source,string code, string culture);
}

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Localization;
public class Class1
{
}

View File

@@ -0,0 +1,14 @@
using ADS.Helpers.Localization.Abstractions;
using Microsoft.Extensions.DependencyInjection;
namespace ADS.Helpers.Localization.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAdsLocalization(this IServiceCollection services)
{
services.AddSingleton<InMemoryMessageCatalog>();
services.AddSingleton<IMessageCatalog>(sp => sp.GetRequiredService<InMemoryMessageCatalog>());
return services;
}
}

View File

@@ -0,0 +1,28 @@
using System.Collections.Concurrent;
using ADS.Helpers.Localization.Abstractions;
namespace ADS.Helpers.Localization;
public sealed class InMemoryMessageCatalog : IMessageCatalog
{
private readonly ConcurrentDictionary<(string source, string code, string culture), string> _messages = new();
public InMemoryMessageCatalog Seed(string source, string culture, IReadOnlyDictionary<string, string> messages)
{
foreach (var kv in messages)
_messages[(source, kv.Key, culture)] = kv.Value;
return this;
}
public string Get(string source, string code, string culture)
{
if (_messages.TryGetValue((source, code, culture), out var msg))
return msg;
if (_messages.TryGetValue((source, code, "en-US"), out var en))
return en;
return code;
}
}

View File

@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.3" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,82 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
namespace ADS.Helpers.Middleware.ADSException;
using ADS.Helpers.Middleware.Http;
public sealed class AdsExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly IAdsErrorLogger _logger;
public AdsExceptionMiddleware(RequestDelegate next, IAdsErrorLogger logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (System.UnauthorizedAccessException ex)
{
await Handle(context, HttpStatusCode.Unauthorized, ex, AdsErrorKind.Controlled);
}
catch (ArgumentException ex)
{
await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
}
catch (InvalidOperationException ex)
{
await Handle(context, HttpStatusCode.BadRequest, ex, AdsErrorKind.Controlled);
}
catch (Microsoft.Data.SqlClient.SqlException ex)
{
await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Sql);
}
catch (Exception ex)
{
await Handle(context, HttpStatusCode.InternalServerError, ex, AdsErrorKind.Unhandled);
}
}
private async Task Handle(HttpContext ctx, HttpStatusCode code, Exception ex, AdsErrorKind kind)
{
await _logger.LogAsync(new AdsErrorEvent
{
Kind = kind,
Path = ctx.Request.Path.ToString(),
Method = ctx.Request.Method,
TraceId = ctx.TraceIdentifier,
Message = ex.Message,
Exception = ex.ToString()
});
ctx.Response.StatusCode = (int)code;
ctx.Response.ContentType = "application/json";
var payload = new ApiResponse<object>
{
StatusCode = (int)code,
StatusCodeText = code.ToString(),
ConsoleMessage = ex.Message,
ScreenMessage = new UiMessage
{
MessageType = 2, // ejemplo: 1=info,2=error,3=warn
Message = "Ocurrió un error al procesar la solicitud.",
MessageCode = 0
},
Value = null
};
await ctx.Response.WriteAsync(JsonSerializer.Serialize(payload, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
}));
}
}

View File

@@ -0,0 +1,24 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.Middleware.ADSException;
public sealed class FileAdsErrorLogger : IAdsErrorLogger
{
private readonly string _folder;
public FileAdsErrorLogger(IOptions<LoggingOptions> options)
{
_folder = options.Value.LogFolder ?? "logs";
Directory.CreateDirectory(_folder);
}
public Task LogAsync(AdsErrorEvent evt, CancellationToken ct = default)
{
var day = DateTime.UtcNow.ToString("yyyy-MM-dd");
var path = Path.Combine(_folder, $"ads-errors-{day}.jsonl");
var line = JsonSerializer.Serialize(evt);
return File.AppendAllTextAsync(path, line + Environment.NewLine, ct);
}
}

View File

@@ -0,0 +1,24 @@
namespace ADS.Helpers.Middleware.ADSException;
public enum AdsErrorKind
{
Sql = 1,
Controlled = 2,
Unhandled = 3
}
public sealed class AdsErrorEvent
{
public AdsErrorKind Kind { get; init; }
public string? Path { get; init; }
public string? Method { get; init; }
public string? TraceId { get; init; }
public string? Message { get; init; }
public string? Exception { get; init; }
public DateTime Utc { get; init; } = DateTime.UtcNow;
}
public interface IAdsErrorLogger
{
Task LogAsync(AdsErrorEvent evt, CancellationToken ct = default);
}

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Middleware.ADSException;
public sealed class LoggingOptions
{
public string LogFolder { get; set; } = "logs";
}

View File

@@ -0,0 +1,34 @@
using ADS.Helpers.Middleware.ADSException;
using ADS.Helpers.Middleware.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ADS.Helpers.Middleware.ADSExtensions;
public static class AdsApiResponseExtensions
{
public static IServiceCollection AddAdsApiResponse(
this IServiceCollection services,
IConfiguration configuration)
{
// Leer configuración desde appsettings de la API
services.Configure<LoggingOptions>(
configuration.GetSection("LoggingOptions"));
services.AddSingleton<IAdsErrorLogger, FileAdsErrorLogger>();
services.AddControllers(o =>
{
o.Filters.Add<ApiResponseWrapFilter>();
});
return services;
}
public static IApplicationBuilder UseAdsApiResponse(this IApplicationBuilder app)
{
app.UseMiddleware<AdsExceptionMiddleware>();
return app;
}
}

View File

@@ -0,0 +1,76 @@
using ADS.Helpers.Middleware.Tenant;
using ADS.Helpers.Middleware.Logging;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Resolvers;
using ADS.Helpers.MultiTenant.Context;
using ADS.Helpers.Base.Abstractions;
using ADS.Helpers.MultiTenant.Configuration;
using ADS.Helpers.MultiTenant.Stores;
using ADS.Helpers.Middleware.InternalUser;
namespace ADS.Helpers.Middleware.Extensions;
public static class AdsExtensions
{
// IServiceCollection extensions
public static IServiceCollection AddAdsLogging(this IServiceCollection services, IConfiguration configuration)
{
//services.AddLogging();
// Ejemplo si tenías esto:
services.AddSingleton<IAppLogger, DefaultAppLogger>();
return services;
}
public static IServiceCollection AddAdsMultiTenant(this IServiceCollection services, IConfiguration configuration)
{
// Aquí va lo que ya tengas para tenant: options, accessor, resolver, etc.
services.Configure<TenantOptions>(configuration.GetSection("MultiTenant"));
services.AddSingleton<ITenantContextAccessor, TenantContextAccessor>();
// services.AddSingleton<ITenantStore, InMemoryTenantStore>();// reemplazado por ConfigurationTenantStore
services.AddSingleton<ITenantStore, ConfigurationTenantStore>();
services.AddScoped<ITenantResolver, HeaderTenantResolver>();
return services;
}
public static IServiceCollection AddAdsMiddlewares(this IServiceCollection services, IConfiguration configuration)
{
// Si este método es solo un "orquestador" de otros registros:
services.AddAdsLogging(configuration);
services.AddAdsMultiTenant(configuration);
// Y aquí agregas otros (ExceptionHandling, CorrelationId, etc.)
return services;
}
// IApplicationBuilder extensions
public static IApplicationBuilder UseAdsTenant(this IApplicationBuilder app)
{
return app.UseMiddleware<TenantMiddleware>();
}
public static IApplicationBuilder UseAdsMiddlewares(this IApplicationBuilder app)
{
// Orden típico: errores/correlation/tenant/etc (según tu diseño)
app.UseAdsTenant();
return app;
}
public static IServiceCollection AddAdsInternalUser(this IServiceCollection services)
{
services.AddScoped<IInternalUserContextAccessor, InternalUserContextAccessor>();
return services;
}
public static IApplicationBuilder UseAdsInternalUser(this IApplicationBuilder app)
{
return app.UseMiddleware<InternalUserContextMiddleware>();
}
}

View File

@@ -0,0 +1,20 @@
namespace ADS.Helpers.Middleware.Http;
public sealed class ApiResponse<T>
{
public int StatusCode { get; init; }
public int TotalRows { get; init; }
public string StatusCodeText { get; init; } = string.Empty;
public string? ConsoleMessage { get; init; }
public UiMessage? ScreenMessage { get; init; }
public T? Value { get; init; }
}
public sealed class UiMessage
{
public int MessageType { get; init; }
public string Message { get; init; } = "";
public int MessageCode { get; init; }
}

View File

@@ -0,0 +1,62 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ADS.Helpers.Middleware.Http;
public sealed class ApiResponseWrapFilter : IAsyncResultFilter
{
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
// Si es FileResult / Stream / etc., no lo toques
if (context.Result is FileResult)
{
await next();
return;
}
if (context.Result is ObjectResult obj)
{
// Si ya viene envuelto, no reenvolver
if (obj.Value is not null)
{
var t = obj.Value.GetType();
if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(ApiResponse<>))
{
await next();
return;
}
}
var code = obj.StatusCode ?? 200;
context.Result = new ObjectResult(new ApiResponse<object>
{
StatusCode = code,
StatusCodeText = ReasonPhrase(code),
Value = obj.Value
})
{ StatusCode = code };
await next();
return;
}
// Si no es ObjectResult (por ejemplo EmptyResult), déjalo pasar
await next();
}
private static string ReasonPhrase(int code) => code switch
{
200 => "OK",
201 => "Created",
202 => "Accepted",
204 => "NoContent",
400 => "BadRequest",
401 => "Unauthorized",
403 => "Forbidden",
404 => "NotFound",
409 => "Conflict",
422 => "UnprocessableEntity",
500 => "ServerError",
_ => "Status"
};
}

View File

@@ -0,0 +1,9 @@
namespace ADS.Helpers.Middleware.Http;
public sealed class PagedQuery
{
public string? Search { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 50;
public bool Pagination { get; init; } = true;
}

View File

@@ -0,0 +1,11 @@
namespace ADS.Helpers.Middleware.Http;
public sealed class RequestOf<T>
{
public T? Value { get; init; }
public string? Search { get; init; }
public int Page { get; init; } = 1;
public int PageSize { get; init; } = 50;
public bool Pagination { get; init; } = true;
}

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Middleware.InternalUser;
public interface IInternalUserContextAccessor
{
InternalUserContext? Current { get; set; }
}

View File

@@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace ADS.Helpers.Middleware.InternalUser;
public class InternalAuthorizeAttribute : Attribute, IAuthorizationFilter
{
public void OnAuthorization(AuthorizationFilterContext context)
{
var allowAnonymous = context.ActionDescriptor.EndpointMetadata
.OfType<AllowAnonymousAttribute>()
.Any();
if (allowAnonymous)
return;
var accessor = context.HttpContext.RequestServices
.GetService(typeof(IInternalUserContextAccessor)) as IInternalUserContextAccessor;
if (accessor?.Current == null)
{
context.Result = new UnauthorizedResult();
}
}
}

View File

@@ -0,0 +1,8 @@
namespace ADS.Helpers.Middleware.InternalUser;
public class InternalUserContext
{
public int CveUser { get; set; }
public int CveEmpresa { get; set; }
public int CveCliente { get; set; }
}

View File

@@ -0,0 +1,6 @@
namespace ADS.Helpers.Middleware.InternalUser;
public class InternalUserContextAccessor : IInternalUserContextAccessor
{
public InternalUserContext? Current { get; set; }
}

View File

@@ -0,0 +1,67 @@
using System.Security.Cryptography;
using System.Text;
using ADS.Helpers.MultiTenant.InternalRouting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
namespace ADS.Helpers.Middleware.InternalUser;
public class InternalUserContextMiddleware
{
private readonly RequestDelegate _next;
public InternalUserContextMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(
HttpContext context,
IInternalUserContextAccessor accessor,
IConfiguration configuration)
{
var cveUserHeader = context.Request.Headers[InternalHeaders.CveUser].FirstOrDefault();
var cveEmpresaHeader = context.Request.Headers[InternalHeaders.CveEmpresa].FirstOrDefault();
var cveClienteHeader = context.Request.Headers[InternalHeaders.CveCliente].FirstOrDefault();
var timestampHeader = context.Request.Headers[InternalHeaders.Timestamp].FirstOrDefault();
var signatureHeader = context.Request.Headers[InternalHeaders.User_Signature].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(cveUserHeader) &&
!string.IsNullOrWhiteSpace(cveEmpresaHeader) &&
!string.IsNullOrWhiteSpace(cveClienteHeader) &&
!string.IsNullOrWhiteSpace(timestampHeader) &&
!string.IsNullOrWhiteSpace(signatureHeader))
{
var secret = configuration["InternalRouting:SharedSecret"];
if (string.IsNullOrWhiteSpace(secret))
throw new InvalidOperationException("InternalRouting:SharedSecret no está configurado.");
var raw = $"{cveUserHeader}|{cveEmpresaHeader}|{cveClienteHeader}|{timestampHeader}";
var expectedSignature = ComputeHmacSha256(raw, secret);
if (signatureHeader == expectedSignature)
{
accessor.Current = new InternalUserContext
{
CveUser = int.Parse(cveUserHeader),
CveEmpresa = int.Parse(cveEmpresaHeader),
CveCliente = int.Parse(cveClienteHeader)
};
}
}
await _next(context);
}
private static string ComputeHmacSha256(string value, string secret)
{
var key = Encoding.UTF8.GetBytes(secret);
var bytes = Encoding.UTF8.GetBytes(value);
using var hmac = new HMACSHA256(key);
var hash = hmac.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}

View File

@@ -0,0 +1,24 @@
using System;
using ADS.Helpers.Base.Abstractions;
using Microsoft.Extensions.Logging;
namespace ADS.Helpers.Middleware.Logging;
public sealed class DefaultAppLogger : IAppLogger
{
private readonly ILogger<DefaultAppLogger> _logger;
public DefaultAppLogger(ILogger<DefaultAppLogger> logger)
{
_logger = logger;
}
public void Info(string source, string code, string message, object? data = null)
=> _logger.LogInformation("{Source} {Code} {Message} {@Data}", source, code, message, data);
public void Warn(string source, string code, string message, object? data = null)
=> _logger.LogWarning("{Source} {Code} {Message} {@Data}", source, code, message, data);
public void Error(string source, string code, string message, Exception? ex = null, object? data = null)
=> _logger.LogError(ex, "{Source} {Code} {Message} {@Data}", source, code, message, data);
}

View File

@@ -0,0 +1,57 @@
using System.Threading.Tasks;
using ADS.Helpers.Base.Abstractions;
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Context;
using Microsoft.AspNetCore.Http;
namespace ADS.Helpers.Middleware.Tenant;
public sealed class TenantMiddleware
{
private readonly RequestDelegate _next;
public TenantMiddleware(RequestDelegate next)
{
_next = next;
}
// Scoped dependencies se resuelven aquí (por request)
public async Task InvokeAsync(
HttpContext context,
ITenantResolver tenantResolver,
ITenantStore tenantStore,
ITenantContextAccessor tenantContextAccessor,
IAppLogger log)
{
var tenantKey = tenantResolver.Resolve(context);
if (string.IsNullOrWhiteSpace(tenantKey))
{
log.Warn("ADS.MultiTenant", "TENANT_NOT_RESOLVED", "Tenant key could not be resolved.");
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsync("Tenant not resolved.");
return;
}
var tenant = await tenantStore.GetAsync(tenantKey);
if (tenant is null)
{
log.Warn("ADS.MultiTenant", "TENANT_NOT_FOUND", $"Tenant '{tenantKey}' not found.");
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsync("Tenant not found.");
return;
}
tenantContextAccessor.SetCurrent(tenant);
try
{
await _next(context);
}
finally
{
tenantContextAccessor.SetCurrent(null);
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.9" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Http;
namespace ADS.Helpers.MultiTenant.Abstractions;
public interface ITenantResolver
{
string? Resolve(HttpContext context);
}

View File

@@ -0,0 +1,9 @@
using System.Threading.Tasks;
using ADS.Helpers.MultiTenant.Context;
namespace ADS.Helpers.MultiTenant.Abstractions;
public interface ITenantStore
{
Task<TenantContext?> GetAsync(string tenantKey);
}

View File

@@ -0,0 +1,11 @@
namespace ADS.Helpers.MultiTenant.Configuration;
public sealed class TenantOptions
{
public string HeaderName { get; set; } = "X-Tenant";
public string? DefaultTenantId { get; set; }="demo";
public bool RequireTenant { get; set; } = false;
// Para no romper swagger
public string[] BypassPaths { get; set; } = ["/swagger"];
}

View File

@@ -0,0 +1,7 @@
namespace ADS.Helpers.MultiTenant.Context;
public interface ITenantContextAccessor
{
TenantContext? Current { get; }
void SetCurrent(TenantContext? context);
}

View File

@@ -0,0 +1,17 @@
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Context;
namespace ADS.Helpers.MultiTenant.Stores;
public sealed class InMemoryTenantStore : ITenantStore
{
private readonly Dictionary<string, TenantContext> _tenants = new(StringComparer.OrdinalIgnoreCase)
{
["demo"] = new TenantContext { TenantId = "demo", EmpresaId = 172, ClienteId = 1 }
};
public Task<TenantContext?> GetAsync(string tenantKey)
{
_tenants.TryGetValue(tenantKey, out var tenant);
return Task.FromResult<TenantContext?>(tenant);
}
}

View File

@@ -0,0 +1,22 @@
namespace ADS.Helpers.MultiTenant.Context;
public sealed class TenantContext
{
public int EmpresaId { get; set; }
public int ClienteId { get; set; }
public string TenantId { get; set; } = string.Empty;
public string Culture { get; set; } = "es-MX";
public string Theme { get; set; } = string.Empty;
public DatabaseConfig Database { get; set; } = new();
// Settings no tipados (solo si quieres algo rápido para cosas raras)
public Dictionary<string, string> CustomSettings { get; set; } = new();
}
public sealed class DatabaseConfig
{
public string Provider { get; set; } = "SqlServer"; // SqlServer | Postgres
public string ConnectionString { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,12 @@
using System.Threading;
namespace ADS.Helpers.MultiTenant.Context;
public sealed class TenantContextAccessor : ITenantContextAccessor
{
private static readonly AsyncLocal<TenantContext?> _current = new();
public TenantContext? Current => _current.Value;
public void SetCurrent(TenantContext? context) => _current.Value = context;
}

View File

@@ -0,0 +1,8 @@
using System.Data;
namespace ADS.Helpers.MultiTenant.Data;
public interface IDbConnectionFactory
{
IDbConnection Create();
}

View File

@@ -0,0 +1,25 @@
using System.Data;
using ADS.Helpers.MultiTenant.InternalRouting;
using Microsoft.AspNetCore.Http;
using Microsoft.Data.SqlClient;
namespace ADS.Helpers.MultiTenant.Data;
public sealed class TenantDbConnectionFactory : IDbConnectionFactory
{
private readonly IHttpContextAccessor _http;
public TenantDbConnectionFactory(IHttpContextAccessor http) => _http = http;
public IDbConnection Create()
{
var ctx = _http.HttpContext ?? throw new InvalidOperationException("HTTP_CONTEXT_NOT_AVAILABLE");
if (!ctx.Items.TryGetValue(InternalTenantDbHeaders.ItemConn, out var csObj) ||
csObj is not string cs ||
string.IsNullOrWhiteSpace(cs))
throw new InvalidOperationException("INTERNAL_CONN_MISSING");
return new SqlConnection(cs);
}
}

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Builder;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class ApplicationBuilderExtensions
{
// MICRO: valida headers internos antes de controllers
public static IApplicationBuilder UseInternalTenantDb(this IApplicationBuilder app)
=> app.UseMiddleware<InternalTenantDbMiddleware>();
}

View File

@@ -0,0 +1,24 @@
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class InternalHeaders
{
public const string Tenant = "X-Internal-Tenant";
public const string Conn = "X-Internal-Conn";
public const string Timestamp = "X-Internal-Ts";
public const string Signature = "X-Internal-Sig";
// NUEVOS (lo que faltaba para no “inventar” en GPS)
public const string EmpresaId = "X-Internal-EmpresaId";
public const string ClienteId = "X-Internal-ClienteId";
// Opcionales
public const string Culture = "X-Internal-Culture";
public const string Theme = "X-Internal-Theme";
public const string DbProvider= "X-Internal-DbProvider";
public const string CveUser = "X-Internal-CveUser";
public const string CveEmpresa = "X-Internal-CveEmpresa";
public const string CveCliente = "X-Internal-CveCliente";
public const string User_Timestamp = "X-Internal-User-Timestamp";
public const string User_Signature = "X-Internal-User-Signature";
}

View File

@@ -0,0 +1,21 @@
namespace ADS.Helpers.MultiTenant.InternalRouting;
public sealed class InternalRoutingOptions
{
public string SharedSecret { get; set; } = default!;
public int MaxSkewSeconds { get; set; } = 120;
public string HeaderTenant { get; set; } = InternalHeaders.Tenant;
public string HeaderConn { get; set; } = InternalHeaders.Conn;
public string HeaderTs { get; set; } = InternalHeaders.Timestamp;
public string HeaderSig { get; set; } = InternalHeaders.Signature;
// NUEVOS
public string HeaderEmpresaId { get; set; } = InternalHeaders.EmpresaId;
public string HeaderClienteId { get; set; } = InternalHeaders.ClienteId;
// Opcionales
public string HeaderCulture { get; set; } = InternalHeaders.Culture;
public string HeaderTheme { get; set; } = InternalHeaders.Theme;
public string HeaderDbProvider{ get; set; } = InternalHeaders.DbProvider;
}

View File

@@ -0,0 +1,43 @@
using System.Security.Cryptography;
using System.Text;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class InternalSignature
{
public static string Compute(
string secret,
string tenantId,
string conn,
int empresaId,
int clienteId,
string provider,
string culture,
string theme,
long tsUnix)
{
var payload = $"{tenantId}\n{conn}\n{empresaId}\n{clienteId}\n{provider}\n{culture}\n{theme}\n{tsUnix}";
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToHexString(hash); // HEX uppercase
}
// Backward compatible (por si algún host viejo sigue firmando con el formato anterior)
public static string Compute(string secret, string tenantId, string conn, long tsUnix)
=> Compute(secret, tenantId, conn, 0, 0, "SqlServer", "es-MX", "", tsUnix);
public static bool FixedTimeEqualsHex(string expectedHex, string providedHex)
{
if (expectedHex.Length != providedHex.Length) return false;
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expectedHex),
Encoding.ASCII.GetBytes(providedHex));
}
public static bool IsTimestampValid(long tsUnix, int maxSkewSeconds)
{
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var diff = Math.Abs(now - tsUnix);
return diff <= maxSkewSeconds;
}
}

View File

@@ -0,0 +1,46 @@
using ADS.Helpers.MultiTenant.Context;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public interface IInternalTenantDbHeaderWriter
{
long AddInternalHeaders(IDictionary<string, string> headers, TenantContext tenant);
}
public sealed class InternalTenantDbHeaderWriter : IInternalTenantDbHeaderWriter
{
private readonly InternalRoutingOptions _opt;
public InternalTenantDbHeaderWriter(IOptions<InternalRoutingOptions> opt) => _opt = opt.Value;
public long AddInternalHeaders(IDictionary<string, string> headers, TenantContext tenant)
{
var ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var tenantId = tenant.TenantId ?? "";
var conn = tenant.Database?.ConnectionString ?? "";
var empresaId = tenant.EmpresaId;
var clienteId = tenant.ClienteId;
var provider = tenant.Database?.Provider ?? "SqlServer";
var culture = tenant.Culture ?? "es-MX";
var theme = tenant.Theme ?? "";
var sig = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts);
headers[_opt.HeaderTenant] = tenantId;
headers[_opt.HeaderConn] = conn;
headers[_opt.HeaderEmpresaId] = empresaId.ToString();
headers[_opt.HeaderClienteId] = clienteId.ToString();
headers[_opt.HeaderDbProvider] = provider;
headers[_opt.HeaderCulture] = culture;
headers[_opt.HeaderTheme] = theme;
headers[_opt.HeaderTs] = ts.ToString();
headers[_opt.HeaderSig] = sig;
return ts;
}
}

View File

@@ -0,0 +1,7 @@
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class InternalTenantDbHeaders
{
public const string ItemTenantId = "Internal.TenantId";
public const string ItemConn = "Internal.Conn";
}

View File

@@ -0,0 +1,134 @@
using ADS.Helpers.MultiTenant.Context;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public sealed class InternalTenantDbMiddleware
{
private readonly RequestDelegate _next;
private readonly InternalRoutingOptions _opt;
private readonly ITenantContextAccessor _tenantAccessor;
public InternalTenantDbMiddleware(
RequestDelegate next,
IOptions<InternalRoutingOptions> opt,
ITenantContextAccessor tenantAccessor)
{
_next = next;
_opt = opt.Value;
_tenantAccessor = tenantAccessor;
}
public async Task InvokeAsync(HttpContext ctx)
{
if (!TryRead(ctx,
out var tenantId,
out var conn,
out var empresaId,
out var clienteId,
out var provider,
out var culture,
out var theme,
out var ts,
out var sig))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
if (!InternalSignature.IsTimestampValid(ts, _opt.MaxSkewSeconds))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
// Firma NUEVA (con empresa/cliente/etc)
var expected = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, empresaId, clienteId, provider, culture, theme, ts);
// Backward compatible: si el Host todavía firma “viejo”, cae al compute viejo
if (!InternalSignature.FixedTimeEqualsHex(expected, sig))
{
var expectedOld = InternalSignature.Compute(_opt.SharedSecret, tenantId, conn, ts);
if (!InternalSignature.FixedTimeEqualsHex(expectedOld, sig))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
}
// backward compatible items
ctx.Items[InternalTenantDbHeaders.ItemTenantId] = tenantId;
ctx.Items[InternalTenantDbHeaders.ItemConn] = conn;
_tenantAccessor.SetCurrent(new TenantContext
{
TenantId = tenantId,
EmpresaId = empresaId,
ClienteId = clienteId,
Culture = culture,
Theme = theme,
Database = new DatabaseConfig
{
Provider = provider,
ConnectionString = conn
}
});
try
{
await _next(ctx);
}
finally
{
_tenantAccessor.SetCurrent(null);
}
}
private bool TryRead(
HttpContext ctx,
out string tenantId,
out string conn,
out int empresaId,
out int clienteId,
out string provider,
out string culture,
out string theme,
out long ts,
out string sig)
{
tenantId = conn = sig = "";
empresaId = 0;
clienteId = 0;
provider = "SqlServer";
culture = "es-MX";
theme = "";
ts = 0;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTenant, out var t)) return false;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderConn, out var c)) return false;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderTs, out var tsStr)) return false;
if (!ctx.Request.Headers.TryGetValue(_opt.HeaderSig, out var s)) return false;
tenantId = t.ToString();
conn = c.ToString();
sig = s.ToString();
// NUEVOS obligatorios para Sicrep (si no vienen, dejamos 0 y el SP puede trabajar con -1)
if (ctx.Request.Headers.TryGetValue(_opt.HeaderEmpresaId, out var eStr))
int.TryParse(eStr.ToString(), out empresaId);
if (ctx.Request.Headers.TryGetValue(_opt.HeaderClienteId, out var clStr))
int.TryParse(clStr.ToString(), out clienteId);
// Opcionales
if (ctx.Request.Headers.TryGetValue(_opt.HeaderDbProvider, out var p)) provider = p.ToString();
if (ctx.Request.Headers.TryGetValue(_opt.HeaderCulture, out var cul)) culture = cul.ToString();
if (ctx.Request.Headers.TryGetValue(_opt.HeaderTheme, out var th)) theme = th.ToString();
return !string.IsNullOrWhiteSpace(tenantId)
&& !string.IsNullOrWhiteSpace(conn)
&& long.TryParse(tsStr.ToString(), out ts)
&& !string.IsNullOrWhiteSpace(sig);
}
}

View File

@@ -0,0 +1,29 @@
using ADS.Helpers.MultiTenant.Context;
using ADS.Helpers.MultiTenant.Data;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ADS.Helpers.MultiTenant.InternalRouting;
public static class ServiceCollectionExtensions
{
// MICRO: registra middleware options + HttpContextAccessor + DB factory
public static IServiceCollection AddInternalTenantDb(this IServiceCollection services, IConfiguration cfg)
{
services.AddHttpContextAccessor();
services.Configure<InternalRoutingOptions>(cfg.GetSection("InternalRouting"));
services.AddSingleton<IDbConnectionFactory, TenantDbConnectionFactory>();
services.AddSingleton<ITenantContextAccessor, TenantContextAccessor>();
services.AddSingleton<IDbConnectionFactory, TenantDbConnectionFactory>();
return services;
}
// HOST: registra solo el writer (para agregar headers internos)
public static IServiceCollection AddInternalTenantDbHeaderWriter(this IServiceCollection services, IConfiguration cfg)
{
services.Configure<InternalRoutingOptions>(cfg.GetSection("InternalRouting"));
services.AddSingleton<IInternalTenantDbHeaderWriter, InternalTenantDbHeaderWriter>();
return services;
}
}

View File

@@ -0,0 +1,25 @@
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Configuration;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
namespace ADS.Helpers.MultiTenant.Resolvers;
public sealed class HeaderTenantResolver : ITenantResolver
{
private readonly TenantOptions _opt;
public HeaderTenantResolver(IOptions<TenantOptions> opt) => _opt = opt.Value;
public string? Resolve(HttpContext context)
{
if (context.Request.Headers.TryGetValue(_opt.HeaderName, out var value))
return value.ToString();
if (!string.IsNullOrWhiteSpace(_opt.DefaultTenantId))
return _opt.DefaultTenantId;
return null;
}
}

View File

@@ -0,0 +1,22 @@
using ADS.Helpers.MultiTenant.Abstractions;
using ADS.Helpers.MultiTenant.Context;
using Microsoft.Extensions.Configuration;
namespace ADS.Helpers.MultiTenant.Stores;
public sealed class ConfigurationTenantStore : ITenantStore
{
private readonly IConfiguration _cfg;
public ConfigurationTenantStore(IConfiguration cfg) => _cfg = cfg;
public Task<TenantContext?> GetAsync(string tenantKey)
{
var section = _cfg.GetSection($"Tenants:{tenantKey}");
if (!section.Exists()) return Task.FromResult<TenantContext?>(null);
var tenant = section.Get<TenantContext>() ?? new TenantContext();
tenant.TenantId = tenantKey; // importante
return Task.FromResult<TenantContext?>(tenant);
}
}

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;
}

137
Helpers/ADS.Helpers.sln Normal file
View File

@@ -0,0 +1,137 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Base", "ADS.Helpers.Base\ADS.Helpers.Base.csproj", "{B582C882-7C6B-4B65-8517-35335E223626}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.MultiTenant", "ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj", "{B57DB90D-2835-4BCC-9319-98F0622A6D33}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Middleware", "ADS.Helpers.Middleware\ADS.Helpers.Middleware.csproj", "{92CA3613-2F92-493E-88E3-EE24DA21CF67}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.NotifyCenter", "ADS.Helpers.NotifyCenter\ADS.Helpers.NotifyCenter.csproj", "{CBD7148D-5203-4A08-9FF1-1EE774495417}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Localization", "ADS.Helpers.Localization\ADS.Helpers.Localization.csproj", "{1B443C1C-500B-45F3-BBC0-3C95F741C976}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Tests", "tests\ADS.Helpers.Tests\ADS.Helpers.Tests.csproj", "{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Auth", "ADS.Helpers.Auth\ADS.Helpers.Auth.csproj", "{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ADS.Helpers.Data", "ADS.Helpers.Data\ADS.Helpers.Data.csproj", "{0889FF69-94B0-47A7-97CB-59130D05243F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x64.ActiveCfg = Debug|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x64.Build.0 = Debug|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x86.ActiveCfg = Debug|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Debug|x86.Build.0 = Debug|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Release|Any CPU.Build.0 = Release|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x64.ActiveCfg = Release|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x64.Build.0 = Release|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x86.ActiveCfg = Release|Any CPU
{B582C882-7C6B-4B65-8517-35335E223626}.Release|x86.Build.0 = Release|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x64.ActiveCfg = Debug|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x64.Build.0 = Debug|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x86.ActiveCfg = Debug|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Debug|x86.Build.0 = Debug|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|Any CPU.Build.0 = Release|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x64.ActiveCfg = Release|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x64.Build.0 = Release|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x86.ActiveCfg = Release|Any CPU
{B57DB90D-2835-4BCC-9319-98F0622A6D33}.Release|x86.Build.0 = Release|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|Any CPU.Build.0 = Debug|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x64.ActiveCfg = Debug|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x64.Build.0 = Debug|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x86.ActiveCfg = Debug|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Debug|x86.Build.0 = Debug|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|Any CPU.ActiveCfg = Release|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|Any CPU.Build.0 = Release|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x64.ActiveCfg = Release|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x64.Build.0 = Release|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x86.ActiveCfg = Release|Any CPU
{92CA3613-2F92-493E-88E3-EE24DA21CF67}.Release|x86.Build.0 = Release|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x64.ActiveCfg = Debug|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x64.Build.0 = Debug|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x86.ActiveCfg = Debug|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Debug|x86.Build.0 = Debug|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|Any CPU.Build.0 = Release|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x64.ActiveCfg = Release|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x64.Build.0 = Release|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x86.ActiveCfg = Release|Any CPU
{CBD7148D-5203-4A08-9FF1-1EE774495417}.Release|x86.Build.0 = Release|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x64.ActiveCfg = Debug|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x64.Build.0 = Debug|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x86.ActiveCfg = Debug|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Debug|x86.Build.0 = Debug|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|Any CPU.Build.0 = Release|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x64.ActiveCfg = Release|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x64.Build.0 = Release|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x86.ActiveCfg = Release|Any CPU
{1B443C1C-500B-45F3-BBC0-3C95F741C976}.Release|x86.Build.0 = Release|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x64.ActiveCfg = Debug|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x64.Build.0 = Debug|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x86.ActiveCfg = Debug|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Debug|x86.Build.0 = Debug|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|Any CPU.Build.0 = Release|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x64.ActiveCfg = Release|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x64.Build.0 = Release|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x86.ActiveCfg = Release|Any CPU
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6}.Release|x86.Build.0 = Release|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x64.ActiveCfg = Debug|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x64.Build.0 = Debug|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x86.ActiveCfg = Debug|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Debug|x86.Build.0 = Debug|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|Any CPU.Build.0 = Release|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x64.ActiveCfg = Release|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x64.Build.0 = Release|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x86.ActiveCfg = Release|Any CPU
{A5CF4A5D-966D-4C9F-A0A2-2D312531E1EE}.Release|x86.Build.0 = Release|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x64.ActiveCfg = Debug|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x64.Build.0 = Debug|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x86.ActiveCfg = Debug|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Debug|x86.Build.0 = Debug|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|Any CPU.Build.0 = Release|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x64.ActiveCfg = Release|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x64.Build.0 = Release|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x86.ActiveCfg = Release|Any CPU
{0889FF69-94B0-47A7-97CB-59130D05243F}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7C8FA91E-D6D0-4D84-B519-DA6E8B6376D6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal

View File

@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Localization" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ADS.Helpers.MultiTenant\ADS.Helpers.MultiTenant.csproj" />
<ProjectReference Include="..\ADS.Helpers.Base\ADS.Helpers.Base.csproj" />
<ProjectReference Include="..\..\ADS.Helpers.Localization\ADS.Helpers.Localization.csproj" />
<ProjectReference Include="..\..\ADS.Helpers.NotifyCenter\ADS.Helpers.NotifyCenter.csproj" />
<ProjectReference Include="..\..\ADS.Helpers.Middleware\ADS.Helpers.Middleware.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,10 @@
namespace ADS.Helpers.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}