Sms & Email
This commit is contained in:
@@ -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>();
|
||||
|
||||
Reference in New Issue
Block a user