Sms & Email

This commit is contained in:
2026-10-20 10:45:03 +05:30
parent a40e463e8b
commit 0e423b806c
29 changed files with 13845 additions and 26 deletions
@@ -154,6 +154,67 @@ public sealed class AuthHexClient : IAuthHexClient
public Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct)
=> CallAsync<AuthHexSessionResult>("alt", "VerifyOTP", request, null, ct);
// ---- Notify -------------------------------------------------------------
public Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> NotifyAsync("email", new { to, subject, htmlBody, textBody }, ct);
public Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> NotifyAsync("sms", new { to, text }, ct);
/// <summary>
/// /api/notify/* is plain JSON (<c>{ results: [{ to, ok, error }] }</c>), not the function
/// envelope the manager routes use, so it bypasses <see cref="CallAsync{T}"/>.
/// </summary>
private async Task<IReadOnlyList<AuthHexNotifyResult>> NotifyAsync(string channel, object body, CancellationToken ct)
{
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/notify/{channel}");
foreach (var (key, value) in AdminHeaders())
httpRequest.Headers.Add(key, value);
httpRequest.Content = JsonContent.Create(body, options: JsonOptions);
HttpResponseMessage httpResponse;
try
{
httpResponse = await _http.SendAsync(httpRequest, ct);
}
catch (HttpRequestException)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service is unreachable.", 503);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new DomainException(ErrorCodes.AuthServiceUnavailable, "The authentication service timed out.", 503);
}
NotifyResponse? payload = null;
try
{
payload = await httpResponse.Content.ReadFromJsonAsync<NotifyResponse>(JsonOptions, ct);
}
catch (JsonException)
{
// Fall through — reported below with the HTTP status.
}
if (!httpResponse.IsSuccessStatusCode || payload?.Results is null)
{
var message = payload?.Message
?? (httpResponse.StatusCode == global::System.Net.HttpStatusCode.NotFound
? "The authentication service does not support sending messages yet (no /api/notify)."
: $"The authentication service could not send the {channel} (HTTP {(int)httpResponse.StatusCode}).");
throw new DomainException(ErrorCodes.AuthUpstreamError, message, 502);
}
return payload.Results;
}
private sealed class NotifyResponse
{
public List<AuthHexNotifyResult>? Results { get; set; }
public string? Message { get; set; }
}
// ---- Transport --------------------------------------------------------
private async Task CallVoidAsync(string routeGroup, string functionName, object payload, string? bearerToken, CancellationToken ct)
@@ -57,4 +57,12 @@ public interface IAuthHexClient
Task<IsAvailableResponse> IsAvailableAsync(IsAvailableRequest request, CancellationToken ct);
Task<SendOtpResponse> SendOtpAsync(SendOtpRequest request, CancellationToken ct);
Task<AuthHexSessionResult> VerifyAltOtpAsync(VerifyAltOtpRequest request, CancellationToken ct);
// Notify (POST /api/notify/email|sms) — service-to-service messaging through AuthHex's own
// SMTP / SMS gateway settings, gated by the admin API key (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
Task<IReadOnlyList<AuthHexNotifyResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<AuthHexNotifyResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Per-recipient outcome of an AuthHex notify call.</summary>
public sealed record AuthHexNotifyResult(string To, bool Ok, string? Error);
@@ -0,0 +1,55 @@
using ERPCore.Infra.Auth.AuthHex;
namespace ERPCore.Infra.Messaging;
/// <summary>Per-recipient outcome of a send.</summary>
public sealed record SendResult(string To, bool Ok, string? Error);
/// <summary>
/// Sends email and SMS. ERPCore holds no SMTP or SMS credentials — the real implementation asks
/// AuthHex to send with its own settings (docs/DAY_END_OWNER_REPORT_PLAN.md §3.2).
/// Implementations report per-recipient failures in the result and only throw when the whole
/// call failed (e.g. AuthHex unreachable).
/// </summary>
public interface IMessageSender
{
Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct);
Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct);
}
/// <summary>Default: sends through AuthHex's /api/notify endpoints (Messaging:Provider = AuthHex).</summary>
public sealed class AuthHexMessageSender : IMessageSender
{
private readonly IAuthHexClient _authHex;
public AuthHexMessageSender(IAuthHexClient authHex) => _authHex = authHex;
public async Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
=> (await _authHex.SendEmailAsync(to, subject, htmlBody, textBody, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
public async Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
=> (await _authHex.SendSmsAsync(to, text, ct)).Select(r => new SendResult(r.To, r.Ok, r.Error)).ToList();
}
/// <summary>
/// Writes messages to the log instead of sending (Messaging:Provider = Log) — for local
/// development and tests, so nothing reaches real phones or inboxes.
/// </summary>
public sealed class LogOnlyMessageSender : IMessageSender
{
private readonly ILogger<LogOnlyMessageSender> _logger;
public LogOnlyMessageSender(ILogger<LogOnlyMessageSender> logger) => _logger = logger;
public Task<IReadOnlyList<SendResult>> SendEmailAsync(IReadOnlyList<string> to, string subject, string htmlBody, string? textBody, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] Email to {To}: {Subject}\n{Body}", string.Join(", ", to), subject, textBody ?? htmlBody);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
public Task<IReadOnlyList<SendResult>> SendSmsAsync(IReadOnlyList<string> to, string text, CancellationToken ct)
{
_logger.LogInformation("[LogOnly] SMS to {To}:\n{Text}", string.Join(", ", to), text);
return Task.FromResult<IReadOnlyList<SendResult>>(to.Select(t => new SendResult(t, true, null)).ToList());
}
}
@@ -0,0 +1,75 @@
using ERPCore.Domain.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ERPCore.Infra.Persistence.Configurations;
/// <summary>Singleton owner-report settings row (mirrors SalesConfigConfiguration).</summary>
public sealed class OwnerReportConfigConfiguration : IEntityTypeConfiguration<OwnerReportConfig>
{
public void Configure(EntityTypeBuilder<OwnerReportConfig> builder)
{
builder.ToTable("owner_report_config", t =>
t.HasCheckConstraint("ck_owner_report_config_singleton", $"`ConfigId` = {OwnerReportConfig.SingletonId}"));
builder.HasKey(c => c.ConfigId);
builder.Property(c => c.ConfigId).ValueGeneratedNever();
builder.Property(c => c.Enabled).IsRequired().HasDefaultValue(false);
// Sentinel = the CLR initializer (true), so an explicit false is still written on insert.
builder.Property(c => c.SmsEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.EmailEnabled).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.IncludeBranchBreakdown).IsRequired().HasDefaultValue(true).HasSentinel(true);
builder.Property(c => c.SendAtLocalTime).IsRequired();
builder.Property(c => c.RowVersion).IsRowVersion();
builder.HasOne(c => c.UpdatedByUser)
.WithMany()
.HasForeignKey(c => c.UpdatedBy)
.OnDelete(DeleteBehavior.Restrict);
}
}
public sealed class OwnerReportRecipientConfiguration : IEntityTypeConfiguration<OwnerReportRecipient>
{
public void Configure(EntityTypeBuilder<OwnerReportRecipient> builder)
{
builder.ToTable("owner_report_recipients");
builder.HasKey(r => r.RecipientId);
builder.Property(r => r.Name).HasMaxLength(100).IsRequired();
builder.Property(r => r.Mobile).HasMaxLength(20);
builder.Property(r => r.Email).HasMaxLength(200);
builder.Property(r => r.IsActive).IsRequired().HasDefaultValue(true).HasSentinel(true);
}
}
public sealed class OwnerReportDispatchConfiguration : IEntityTypeConfiguration<OwnerReportDispatch>
{
public void Configure(EntityTypeBuilder<OwnerReportDispatch> builder)
{
builder.ToTable("owner_report_dispatches");
builder.HasKey(d => d.DispatchId);
builder.Property(d => d.Channel).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Status).HasConversion<string>().HasMaxLength(10).IsRequired();
builder.Property(d => d.Destination).HasMaxLength(200).IsRequired();
builder.Property(d => d.RecipientName).HasMaxLength(100).IsRequired();
builder.Property(d => d.Error).HasMaxLength(1000);
builder.Property(d => d.DedupeKey).HasMaxLength(40).IsRequired();
// The "send once" guard: one automatic send per day, channel and recipient. Test sends get
// a unique DedupeKey so any number of them can coexist.
builder.HasIndex(d => new { d.BusinessDate, d.Channel, d.RecipientId, d.IsTest, d.DedupeKey })
.IsUnique()
.HasDatabaseName("ux_owner_report_dispatch_once");
builder.HasIndex(d => new { d.Status, d.NextAttemptAt });
builder.HasIndex(d => d.CreatedAt);
// Recipients can be removed; their past sends stay in the log (Destination/RecipientName keep the detail).
builder.HasOne(d => d.Recipient)
.WithMany()
.HasForeignKey(d => d.RecipientId)
.OnDelete(DeleteBehavior.SetNull);
}
}
@@ -64,6 +64,7 @@ public static class DataSeeder
dirty |= await SeedProcurementConfigAsync(db, ct);
dirty |= await SeedGlAccountConfigAsync(db, configuration, ct);
dirty |= await SeedNotificationConfigAsync(db, ct);
dirty |= await SeedOwnerReportConfigAsync(db, ct);
if (dirty) await db.SaveChangesAsync(ct);
@@ -309,6 +310,15 @@ public static class DataSeeder
});
return true;
}
// Off by default: the owner report only starts sending once someone adds recipients and switches it on.
private static async Task<bool> SeedOwnerReportConfigAsync(ErpDbContext db, CancellationToken ct)
{
if (await db.OwnerReportConfig.AnyAsync(c => c.ConfigId == OwnerReportConfig.SingletonId, ct)) return false;
db.OwnerReportConfig.Add(new OwnerReportConfig { ConfigId = OwnerReportConfig.SingletonId, Enabled = false });
return true;
}
private static async Task<bool> SeedNotificationConfigAsync(ErpDbContext db, CancellationToken ct)
{
@@ -55,6 +55,9 @@ public class ErpDbContext : DbContext
public DbSet<GlAccountConfig> GlAccountConfig => Set<GlAccountConfig>();
/// <summary>Singleton row for notification delivery preferences.</summary>
public DbSet<NotificationConfig> NotificationConfig => Set<NotificationConfig>();
public DbSet<OwnerReportConfig> OwnerReportConfig => Set<OwnerReportConfig>();
public DbSet<OwnerReportRecipient> OwnerReportRecipients => Set<OwnerReportRecipient>();
public DbSet<OwnerReportDispatch> OwnerReportDispatches => Set<OwnerReportDispatch>();
public DbSet<NotificationConfigModule> NotificationConfigModules => Set<NotificationConfigModule>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<NotificationRecipient> NotificationRecipients => Set<NotificationRecipient>();