@@ -1,6 +1,5 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.Services.Notifications;
|
using ROLAC.API.Services.Notifications;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -8,6 +7,14 @@ namespace ROLAC.API.Tests.Services.Notifications;
|
|||||||
|
|
||||||
public class LineMessageChannelTests
|
public class LineMessageChannelTests
|
||||||
{
|
{
|
||||||
|
// Stub settings provider returning fixed SMTP/Line values for the channel under test.
|
||||||
|
private sealed class StubSettings : INotificationSettingsService
|
||||||
|
{
|
||||||
|
public SmtpOptions GetSmtp() => new();
|
||||||
|
public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" };
|
||||||
|
public void Reload() { }
|
||||||
|
}
|
||||||
|
|
||||||
// Captures the outgoing request and returns a canned response.
|
// Captures the outgoing request and returns a canned response.
|
||||||
private sealed class CapturingHandler : HttpMessageHandler
|
private sealed class CapturingHandler : HttpMessageHandler
|
||||||
{
|
{
|
||||||
@@ -28,8 +35,7 @@ public class LineMessageChannelTests
|
|||||||
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
private static LineMessageChannel BuildChannel(CapturingHandler handler)
|
||||||
{
|
{
|
||||||
var http = new HttpClient(handler);
|
var http = new HttpClient(handler);
|
||||||
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
|
return new LineMessageChannel(http, new StubSettings());
|
||||||
return new LineMessageChannel(http, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public static class Modules
|
|||||||
public const string Permissions = "Permissions";
|
public const string Permissions = "Permissions";
|
||||||
public const string SystemLogs = "SystemLogs";
|
public const string SystemLogs = "SystemLogs";
|
||||||
public const string AuditLogs = "AuditLogs";
|
public const string AuditLogs = "AuditLogs";
|
||||||
|
public const string Settings = "Settings";
|
||||||
|
|
||||||
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
|
||||||
public static readonly IReadOnlyList<string> All =
|
public static readonly IReadOnlyList<string> All =
|
||||||
@@ -43,6 +44,7 @@ public static class Modules
|
|||||||
Permissions,
|
Permissions,
|
||||||
SystemLogs,
|
SystemLogs,
|
||||||
AuditLogs,
|
AuditLogs,
|
||||||
|
Settings,
|
||||||
];
|
];
|
||||||
|
|
||||||
public static bool IsValid(string module) => All.Contains(module);
|
public static bool IsValid(string module) => All.Contains(module);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.DTOs.Notifications;
|
using ROLAC.API.DTOs.Notifications;
|
||||||
using ROLAC.API.Services.Notifications;
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
@@ -22,14 +21,14 @@ public sealed class LineWebhookController : ControllerBase
|
|||||||
|
|
||||||
private readonly ILineNotificationService _line;
|
private readonly ILineNotificationService _line;
|
||||||
private readonly IMessageChannel _channel;
|
private readonly IMessageChannel _channel;
|
||||||
private readonly LineOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public LineWebhookController(
|
public LineWebhookController(
|
||||||
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
|
ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings)
|
||||||
{
|
{
|
||||||
_line = line;
|
_line = line;
|
||||||
_channel = channel;
|
_channel = channel;
|
||||||
_options = options.Value;
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("webhook")]
|
[HttpPost("webhook")]
|
||||||
@@ -40,7 +39,7 @@ public sealed class LineWebhookController : ControllerBase
|
|||||||
var rawBody = await reader.ReadToEndAsync(ct);
|
var rawBody = await reader.ReadToEndAsync(ct);
|
||||||
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
|
||||||
|
|
||||||
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
if (!LineSignature.IsValid(_settings.GetLine().ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
|
||||||
return BadRequest();
|
return BadRequest();
|
||||||
|
|
||||||
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ROLAC.API.Authorization;
|
||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
using ROLAC.API.Services;
|
||||||
|
using ROLAC.API.Services.Logging;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site /
|
||||||
|
/// Notification tabs. Gated by the <c>Settings</c> permission module (super_admin bypasses).
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/settings")]
|
||||||
|
[Authorize]
|
||||||
|
public class SettingsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly ISettingsService _settings;
|
||||||
|
private readonly IEmailService _email;
|
||||||
|
private readonly ILineNotificationService _line;
|
||||||
|
private readonly CurrentUserAccessor _currentUser;
|
||||||
|
|
||||||
|
public SettingsController(
|
||||||
|
ISettingsService settings,
|
||||||
|
IEmailService email,
|
||||||
|
ILineNotificationService line,
|
||||||
|
CurrentUserAccessor currentUser)
|
||||||
|
{
|
||||||
|
_settings = settings;
|
||||||
|
_email = email;
|
||||||
|
_line = line;
|
||||||
|
_currentUser = currentUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Site settings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetSite() => Ok(await _settings.GetSiteAsync());
|
||||||
|
|
||||||
|
[HttpPut("site")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateSite([FromBody] UpdateSiteSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateSiteAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[HttpGet("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Read)]
|
||||||
|
public async Task<IActionResult> GetNotification()
|
||||||
|
{
|
||||||
|
var dto = await _settings.GetNotificationAsync();
|
||||||
|
dto.WebhookUrl = $"{Request.Scheme}://{Request.Host}/api/line/webhook";
|
||||||
|
return Ok(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("notification")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> UpdateNotification([FromBody] UpdateNotificationSettingRequest request)
|
||||||
|
{
|
||||||
|
await _settings.UpdateNotificationAsync(request);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-email")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestEmail([FromBody] TestEmailRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var to = string.IsNullOrWhiteSpace(request.ToAddress) ? _currentUser.Email : request.ToAddress;
|
||||||
|
if (string.IsNullOrWhiteSpace(to))
|
||||||
|
return BadRequest(new { message = "No recipient — provide an address or set an email on your account." });
|
||||||
|
|
||||||
|
var result = await _email.SendAsync(new EmailMessage(
|
||||||
|
MemberIds: Array.Empty<int>(),
|
||||||
|
Addresses: new[] { to },
|
||||||
|
Subject: "ROLAC test email / 測試郵件",
|
||||||
|
HtmlBody: "<p>This is a test email from ROLAC notification settings.</p>"
|
||||||
|
+ "<p>這是來自 ROLAC 通知設定的測試郵件。</p>",
|
||||||
|
SentByUserId: _currentUser.UserIdOrSystem), ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("notification/test-line")]
|
||||||
|
[HasPermission(Modules.Settings, PermissionActions.Write)]
|
||||||
|
public async Task<IActionResult> TestLine([FromBody] TestLineRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (request.MemberId is null && request.GroupId is null)
|
||||||
|
return BadRequest(new { message = "Choose a bound member or group to receive the test." });
|
||||||
|
|
||||||
|
var result = await _line.SendLineAsync(
|
||||||
|
body: "ROLAC 測試訊息 / This is a test Line message from ROLAC.",
|
||||||
|
memberIds: request.MemberId is { } m ? new[] { m } : Array.Empty<int>(),
|
||||||
|
groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty<int>(),
|
||||||
|
sentByUserId: _currentUser.UserIdOrSystem,
|
||||||
|
ct);
|
||||||
|
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ public class ChurchProfileDto
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = "";
|
public string Name { get; set; } = "";
|
||||||
|
public string? NameZh { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
@@ -18,6 +22,10 @@ public class ChurchProfileDto
|
|||||||
public class UpdateChurchProfileRequest
|
public class UpdateChurchProfileRequest
|
||||||
{
|
{
|
||||||
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
[Required, MaxLength(200)] public string Name { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? NameZh { get; set; }
|
||||||
|
[MaxLength(50)] public string? Phone { get; set; }
|
||||||
|
[MaxLength(200), EmailAddress] public string? Email { get; set; }
|
||||||
|
[MaxLength(300)] public string? Website { get; set; }
|
||||||
[MaxLength(500)] public string? Address { get; set; }
|
[MaxLength(500)] public string? Address { get; set; }
|
||||||
[MaxLength(100)] public string? City { get; set; }
|
[MaxLength(100)] public string? City { get; set; }
|
||||||
[MaxLength(50)] public string? State { get; set; }
|
[MaxLength(50)] public string? State { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
namespace ROLAC.API.DTOs.Settings;
|
||||||
|
|
||||||
|
// ── Site settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class SiteSettingDto
|
||||||
|
{
|
||||||
|
public string SiteTitle { get; set; } = "";
|
||||||
|
public string? SiteTitleZh { get; set; }
|
||||||
|
public string DefaultLanguage { get; set; } = "en";
|
||||||
|
public string TimeZone { get; set; } = "";
|
||||||
|
public string DateFormat { get; set; } = "";
|
||||||
|
public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateSiteSettingRequest
|
||||||
|
{
|
||||||
|
[Required, MaxLength(200)] public string SiteTitle { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? SiteTitleZh { get; set; }
|
||||||
|
[Required, MaxLength(10)] public string DefaultLanguage { get; set; } = "en";
|
||||||
|
[Required, MaxLength(100)] public string TimeZone { get; set; } = "";
|
||||||
|
[Required, MaxLength(50)] public string DateFormat { get; set; } = "";
|
||||||
|
[Required, MaxLength(10)] public string Currency { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification settings ──────────────────────────────────────────────────
|
||||||
|
// Secrets are never returned. The DTO exposes only whether each secret is configured; the UI
|
||||||
|
// shows a write-only field where a blank value on update means "keep the stored secret".
|
||||||
|
|
||||||
|
public class NotificationSettingDto
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
public string SmtpHost { get; set; } = "";
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
public string SmtpUser { get; set; } = "";
|
||||||
|
public string FromAddress { get; set; } = "";
|
||||||
|
public string FromName { get; set; } = "";
|
||||||
|
public bool HasSmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
public bool HasLineChannelAccessToken { get; set; }
|
||||||
|
public bool HasLineChannelSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Read-only webhook URL to register in the Line console (derived from the request).</summary>
|
||||||
|
public string WebhookUrl { get; set; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateNotificationSettingRequest
|
||||||
|
{
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
[MaxLength(200)] public string SmtpHost { get; set; } = "";
|
||||||
|
[Range(0, 65535)] public int SmtpPort { get; set; } = 587;
|
||||||
|
public bool SmtpUseSsl { get; set; } = true;
|
||||||
|
[MaxLength(200)] public string SmtpUser { get; set; } = "";
|
||||||
|
[MaxLength(200)] public string? FromAddress { get; set; }
|
||||||
|
[MaxLength(200)] public string? FromName { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored password unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? SmtpPassword { get; set; }
|
||||||
|
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored token unchanged.</summary>
|
||||||
|
[MaxLength(500)] public string? LineChannelAccessToken { get; set; }
|
||||||
|
/// <summary>Blank = keep the stored secret unchanged.</summary>
|
||||||
|
[MaxLength(200)] public string? LineChannelSecret { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test-send requests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public class TestEmailRequest
|
||||||
|
{
|
||||||
|
/// <summary>Optional override; defaults to the current user's email when omitted.</summary>
|
||||||
|
[MaxLength(200), EmailAddress] public string? ToAddress { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestLineRequest
|
||||||
|
{
|
||||||
|
public int? MemberId { get; set; }
|
||||||
|
public int? GroupId { get; set; }
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||||
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||||
|
|
||||||
|
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
|
||||||
|
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
@@ -245,12 +248,43 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
|||||||
entity.Property(e => e.BankName).HasMaxLength(200);
|
entity.Property(e => e.BankName).HasMaxLength(200);
|
||||||
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
entity.Property(e => e.BankAccountNumber).HasMaxLength(50);
|
||||||
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
entity.Property(e => e.BankRoutingNumber).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.NameZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Phone).HasMaxLength(50);
|
||||||
|
entity.Property(e => e.Email).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.Website).HasMaxLength(300);
|
||||||
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
// Optimistic-concurrency token for safe check-number allocation.
|
// Optimistic-concurrency token for safe check-number allocation.
|
||||||
entity.Property(e => e.xmin).IsRowVersion();
|
entity.Property(e => e.xmin).IsRowVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── SiteSetting (singleton presentation/locale settings) ─────────────
|
||||||
|
builder.Entity<SiteSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SiteTitle).HasMaxLength(200).IsRequired();
|
||||||
|
entity.Property(e => e.SiteTitleZh).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.DefaultLanguage).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.TimeZone).HasMaxLength(100).IsRequired();
|
||||||
|
entity.Property(e => e.DateFormat).HasMaxLength(50).IsRequired();
|
||||||
|
entity.Property(e => e.Currency).HasMaxLength(10).IsRequired();
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── NotificationSetting (singleton SMTP + Line settings) ─────────────
|
||||||
|
builder.Entity<NotificationSetting>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.SmtpHost).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpUser).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.SmtpPassword).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.FromAddress).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.FromName).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.LineChannelAccessToken).HasMaxLength(500);
|
||||||
|
entity.Property(e => e.LineChannelSecret).HasMaxLength(200);
|
||||||
|
entity.Property(e => e.CreatedBy).HasMaxLength(450);
|
||||||
|
entity.Property(e => e.UpdatedBy).HasMaxLength(450);
|
||||||
|
});
|
||||||
|
|
||||||
// ── Check (disbursement) ─────────────────────────────────────────────
|
// ── Check (disbursement) ─────────────────────────────────────────────
|
||||||
builder.Entity<Check>(entity =>
|
builder.Entity<Check>(entity =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -208,6 +208,50 @@ public static class DbSeeder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task SeedSiteSettingAsync(AppDbContext db)
|
||||||
|
{
|
||||||
|
// Singleton row holding site-wide presentation/locale settings.
|
||||||
|
if (!await db.SiteSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
db.SiteSettings.Add(new SiteSetting
|
||||||
|
{
|
||||||
|
SiteTitle = "River Of Life Christian Church",
|
||||||
|
SiteTitleZh = "生命河靈糧堂",
|
||||||
|
DefaultLanguage = "en",
|
||||||
|
TimeZone = "America/Los_Angeles",
|
||||||
|
DateFormat = "yyyy-MM-dd",
|
||||||
|
Currency = "USD",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task SeedNotificationSettingAsync(AppDbContext db, IConfiguration config)
|
||||||
|
{
|
||||||
|
// Singleton row that becomes the runtime source of truth for SMTP + Line. Seed it once
|
||||||
|
// from the legacy "Smtp"/"Line" appsettings sections so existing config carries over.
|
||||||
|
if (!await db.NotificationSettings.AnyAsync())
|
||||||
|
{
|
||||||
|
var smtp = config.GetSection("Smtp");
|
||||||
|
var line = config.GetSection("Line");
|
||||||
|
db.NotificationSettings.Add(new NotificationSetting
|
||||||
|
{
|
||||||
|
EnableEmail = !string.IsNullOrWhiteSpace(smtp["Host"]),
|
||||||
|
SmtpHost = smtp["Host"] ?? "",
|
||||||
|
SmtpPort = int.TryParse(smtp["Port"], out var port) ? port : 587,
|
||||||
|
SmtpUseSsl = !bool.TryParse(smtp["UseSsl"], out var ssl) || ssl,
|
||||||
|
SmtpUser = smtp["User"] ?? "",
|
||||||
|
SmtpPassword = smtp["Password"] ?? "",
|
||||||
|
FromAddress = smtp["FromAddress"] ?? "",
|
||||||
|
FromName = smtp["FromName"] ?? "",
|
||||||
|
EnableLine = !string.IsNullOrWhiteSpace(line["ChannelAccessToken"]),
|
||||||
|
LineChannelAccessToken = line["ChannelAccessToken"] ?? "",
|
||||||
|
LineChannelSecret = line["ChannelSecret"] ?? "",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Seeds roles and (in Development) the default admin account.
|
/// Seeds roles and (in Development) the default admin account.
|
||||||
/// Called once on application startup after migrations have been applied.
|
/// Called once on application startup after migrations have been applied.
|
||||||
@@ -217,6 +261,7 @@ public static class DbSeeder
|
|||||||
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
var roleManager = services.GetRequiredService<RoleManager<AppRole>>();
|
||||||
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
var userManager = services.GetRequiredService<UserManager<AppUser>>();
|
||||||
var env = services.GetRequiredService<IWebHostEnvironment>();
|
var env = services.GetRequiredService<IWebHostEnvironment>();
|
||||||
|
var config = services.GetRequiredService<IConfiguration>();
|
||||||
|
|
||||||
await SeedRolesAsync(roleManager);
|
await SeedRolesAsync(roleManager);
|
||||||
|
|
||||||
@@ -226,6 +271,8 @@ public static class DbSeeder
|
|||||||
await SeedMinistriesAsync(db);
|
await SeedMinistriesAsync(db);
|
||||||
await SeedExpenseCategoriesAsync(db);
|
await SeedExpenseCategoriesAsync(db);
|
||||||
await SeedChurchProfileAsync(db);
|
await SeedChurchProfileAsync(db);
|
||||||
|
await SeedSiteSettingAsync(db);
|
||||||
|
await SeedNotificationSettingAsync(db, config);
|
||||||
|
|
||||||
if (env.IsDevelopment())
|
if (env.IsDevelopment())
|
||||||
await SeedAdminUserAsync(userManager);
|
await SeedAdminUserAsync(userManager);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ public class ChurchProfile : AuditableEntity, IAuditable
|
|||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
|
public string? NameZh { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Website { get; set; }
|
||||||
public string? Address { get; set; }
|
public string? Address { get; set; }
|
||||||
public string? City { get; set; }
|
public string? City { get; set; }
|
||||||
public string? State { get; set; }
|
public string? State { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton (Id == 1) holding the editable SMTP + Line notification settings. This row — not the
|
||||||
|
/// "Smtp"/"Line" appsettings sections — is the runtime source of truth; those sections only seed
|
||||||
|
/// this row once on first startup. Read at send time via <c>INotificationSettingsService</c> so
|
||||||
|
/// edits apply without restarting the API.
|
||||||
|
///
|
||||||
|
/// Secrets (<see cref="SmtpPassword"/>, <see cref="LineChannelAccessToken"/>,
|
||||||
|
/// <see cref="LineChannelSecret"/>) are stored plaintext and protected by RBAC (the <c>Settings</c>
|
||||||
|
/// module / super_admin) per the project decision for this small single-VM internal app.
|
||||||
|
/// </summary>
|
||||||
|
public class NotificationSetting : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// ── Email (SMTP) ─────────────────────────────────────────────────────────
|
||||||
|
public bool EnableEmail { get; set; }
|
||||||
|
public string SmtpHost { get; set; } = "";
|
||||||
|
public int SmtpPort { get; set; } = 587;
|
||||||
|
public bool SmtpUseSsl { get; set; } = true; // true → STARTTLS
|
||||||
|
public string SmtpUser { get; set; } = "";
|
||||||
|
public string SmtpPassword { get; set; } = "";
|
||||||
|
public string FromAddress { get; set; } = "";
|
||||||
|
public string FromName { get; set; } = "";
|
||||||
|
|
||||||
|
// ── Line ─────────────────────────────────────────────────────────────────
|
||||||
|
public bool EnableLine { get; set; }
|
||||||
|
public string LineChannelAccessToken { get; set; } = "";
|
||||||
|
public string LineChannelSecret { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
using ROLAC.API.Entities.Base;
|
||||||
|
namespace ROLAC.API.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Singleton (Id == 1) holding site-wide presentation and locale settings, edited from the
|
||||||
|
/// Church Profile → Site Settings tab (gated by the <c>Settings</c> permission module).
|
||||||
|
/// Seeded with sensible defaults on startup.
|
||||||
|
/// </summary>
|
||||||
|
public class SiteSetting : AuditableEntity, IAuditable
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string SiteTitle { get; set; } = "";
|
||||||
|
public string? SiteTitleZh { get; set; }
|
||||||
|
public string DefaultLanguage { get; set; } = "en"; // "en" | "zh"
|
||||||
|
public string TimeZone { get; set; } = "America/Los_Angeles";
|
||||||
|
public string DateFormat { get; set; } = "yyyy-MM-dd";
|
||||||
|
public string Currency { get; set; } = "USD";
|
||||||
|
}
|
||||||
@@ -463,14 +463,26 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("NameZh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
b.Property<int>("NextCheckNumber")
|
b.Property<int>("NextCheckNumber")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
b.Property<string>("State")
|
b.Property<string>("State")
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
.HasColumnType("character varying(50)");
|
.HasColumnType("character varying(50)");
|
||||||
@@ -483,6 +495,10 @@ namespace ROLAC.API.Migrations
|
|||||||
.HasMaxLength(450)
|
.HasMaxLength(450)
|
||||||
.HasColumnType("character varying(450)");
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Website")
|
||||||
|
.HasMaxLength(300)
|
||||||
|
.HasColumnType("character varying(300)");
|
||||||
|
|
||||||
b.Property<string>("ZipCode")
|
b.Property<string>("ZipCode")
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
@@ -1323,6 +1339,82 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("MonthlyStatements");
|
b.ToTable("MonthlyStatements");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.NotificationSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<bool>("EnableEmail")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("EnableLine")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("FromAddress")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("FromName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("LineChannelAccessToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<string>("LineChannelSecret")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpHost")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
|
b.Property<int>("SmtpPort")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<bool>("SmtpUseSsl")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("SmtpUser")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("NotificationSettings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -1653,6 +1745,64 @@ namespace ROLAC.API.Migrations
|
|||||||
b.ToTable("RolePermissions");
|
b.ToTable("RolePermissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ROLAC.API.Entities.SiteSetting", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("DateFormat")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultLanguage")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(10)
|
||||||
|
.HasColumnType("character varying(10)");
|
||||||
|
|
||||||
|
b.Property<string>("SiteTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("SiteTitleZh")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("TimeZone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(450)
|
||||||
|
.HasColumnType("character varying(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("SiteSettings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
b.HasOne("ROLAC.API.Entities.AppRole", null)
|
||||||
|
|||||||
@@ -155,14 +155,19 @@ builder.Services.AddScoped<IExpenseService, ExpenseService>();
|
|||||||
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
|
||||||
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
|
||||||
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
|
||||||
|
builder.Services.AddScoped<ISettingsService, SettingsService>();
|
||||||
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
|
||||||
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
ROLAC.API.Services.Disbursement.CheckPrintService>();
|
||||||
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
|
||||||
|
|
||||||
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
|
||||||
|
// IOptions binding stays only as the one-time seed/fallback; the runtime source of truth is the
|
||||||
|
// DB-backed NotificationSetting row, read (and hot-reloaded) via INotificationSettingsService.
|
||||||
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
|
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
|
||||||
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
|
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
|
||||||
|
builder.Services.AddSingleton<ROLAC.API.Services.Notifications.INotificationSettingsService,
|
||||||
|
ROLAC.API.Services.Notifications.NotificationSettingsService>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
|
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
|
||||||
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
|
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
|
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
var p = await GetOrCreateAsync();
|
var p = await GetOrCreateAsync();
|
||||||
return new ChurchProfileDto
|
return new ChurchProfileDto
|
||||||
{
|
{
|
||||||
Id = p.Id, Name = p.Name, Address = p.Address, City = p.City, State = p.State,
|
Id = p.Id, Name = p.Name, NameZh = p.NameZh, Phone = p.Phone, Email = p.Email,
|
||||||
|
Website = p.Website, Address = p.Address, City = p.City, State = p.State,
|
||||||
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
ZipCode = p.ZipCode, BankName = p.BankName, BankAccountNumber = p.BankAccountNumber,
|
||||||
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
|
||||||
};
|
};
|
||||||
@@ -24,7 +25,8 @@ public class ChurchProfileService : IChurchProfileService
|
|||||||
public async Task UpdateAsync(UpdateChurchProfileRequest r)
|
public async Task UpdateAsync(UpdateChurchProfileRequest r)
|
||||||
{
|
{
|
||||||
var p = await GetOrCreateAsync();
|
var p = await GetOrCreateAsync();
|
||||||
p.Name = r.Name; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
p.Name = r.Name; p.NameZh = r.NameZh; p.Phone = r.Phone; p.Email = r.Email;
|
||||||
|
p.Website = r.Website; p.Address = r.Address; p.City = r.City; p.State = r.State;
|
||||||
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
p.ZipCode = r.ZipCode; p.BankName = r.BankName; p.BankAccountNumber = r.BankAccountNumber;
|
||||||
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
p.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads and writes the singleton SiteSetting and NotificationSetting rows. Notification secrets
|
||||||
|
/// are masked on read and treated as write-only on update (blank = keep). After a notification
|
||||||
|
/// update the runtime cache is reloaded so changes apply without an API restart.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISettingsService
|
||||||
|
{
|
||||||
|
Task<SiteSettingDto> GetSiteAsync();
|
||||||
|
Task UpdateSiteAsync(UpdateSiteSettingRequest request);
|
||||||
|
|
||||||
|
Task<NotificationSettingDto> GetNotificationAsync();
|
||||||
|
Task UpdateNotificationAsync(UpdateNotificationSettingRequest request);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Notifications;
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
@@ -11,12 +10,12 @@ public sealed class LineMessageChannel : IMessageChannel
|
|||||||
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
|
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly LineOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
|
public LineMessageChannel(HttpClient http, INotificationSettingsService settings)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_options = options.Value;
|
_settings = settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
|
||||||
@@ -36,7 +35,8 @@ public sealed class LineMessageChannel : IMessageChannel
|
|||||||
{
|
{
|
||||||
Content = JsonContent.Create(payload),
|
Content = JsonContent.Create(payload),
|
||||||
};
|
};
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
|
request.Headers.Authorization =
|
||||||
|
new AuthenticationHeaderValue("Bearer", _settings.GetLine().ChannelAccessToken);
|
||||||
|
|
||||||
using var response = await _http.SendAsync(request, ct);
|
using var response = await _http.SendAsync(request, ct);
|
||||||
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
|
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
using MailKit.Net.Smtp;
|
using MailKit.Net.Smtp;
|
||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Notifications;
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary>
|
/// <summary>Sends a single email via MailKit using the current (DB-backed) SMTP settings.</summary>
|
||||||
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
||||||
{
|
{
|
||||||
private readonly SmtpOptions _options;
|
private readonly INotificationSettingsService _settings;
|
||||||
|
|
||||||
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
|
public MailKitSmtpDispatcher(INotificationSettingsService settings) => _settings = settings;
|
||||||
|
|
||||||
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var options = _settings.GetSmtp();
|
||||||
|
|
||||||
var message = new MimeMessage();
|
var message = new MimeMessage();
|
||||||
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
|
message.From.Add(new MailboxAddress(options.FromName, options.FromAddress));
|
||||||
message.To.Add(MailboxAddress.Parse(email.ToAddress));
|
message.To.Add(MailboxAddress.Parse(email.ToAddress));
|
||||||
message.Subject = email.Subject;
|
message.Subject = email.Subject;
|
||||||
|
|
||||||
@@ -28,10 +29,10 @@ public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
|
|||||||
message.Body = builder.ToMessageBody();
|
message.Body = builder.ToMessageBody();
|
||||||
|
|
||||||
using var client = new SmtpClient();
|
using var client = new SmtpClient();
|
||||||
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
var socketOptions = options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||||
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
|
await client.ConnectAsync(options.Host, options.Port, socketOptions, ct);
|
||||||
if (!string.IsNullOrEmpty(_options.User))
|
if (!string.IsNullOrEmpty(options.User))
|
||||||
await client.AuthenticateAsync(_options.User, _options.Password, ct);
|
await client.AuthenticateAsync(options.User, options.Password, ct);
|
||||||
await client.SendAsync(message, ct);
|
await client.SendAsync(message, ct);
|
||||||
await client.DisconnectAsync(true, ct);
|
await client.DisconnectAsync(true, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supplies the current SMTP/Line settings from the <c>NotificationSetting</c> singleton row,
|
||||||
|
/// caching a snapshot in memory so send paths don't hit the DB on every message. Registered as a
|
||||||
|
/// singleton; the Settings UI calls <see cref="Reload"/> after an edit so changes take effect
|
||||||
|
/// without restarting the API. Falls back to the "Smtp"/"Line" appsettings sections if the row
|
||||||
|
/// has not been seeded yet.
|
||||||
|
/// </summary>
|
||||||
|
public interface INotificationSettingsService
|
||||||
|
{
|
||||||
|
SmtpOptions GetSmtp();
|
||||||
|
LineOptions GetLine();
|
||||||
|
void Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class NotificationSettingsService : INotificationSettingsService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IOptions<SmtpOptions> _smtpFallback;
|
||||||
|
private readonly IOptions<LineOptions> _lineFallback;
|
||||||
|
private readonly object _gate = new();
|
||||||
|
|
||||||
|
private SmtpOptions? _smtp;
|
||||||
|
private LineOptions? _line;
|
||||||
|
|
||||||
|
public NotificationSettingsService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
IOptions<SmtpOptions> smtpFallback,
|
||||||
|
IOptions<LineOptions> lineFallback)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_smtpFallback = smtpFallback;
|
||||||
|
_lineFallback = lineFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmtpOptions GetSmtp()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _smtp!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LineOptions GetLine()
|
||||||
|
{
|
||||||
|
EnsureLoaded();
|
||||||
|
return _line!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reload()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
_smtp = null;
|
||||||
|
_line = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnsureLoaded()
|
||||||
|
{
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_smtp is not null && _line is not null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var row = db.NotificationSettings.AsNoTracking().OrderBy(s => s.Id).FirstOrDefault();
|
||||||
|
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
// Not seeded yet — use the appsettings values so sends still work.
|
||||||
|
_smtp = _smtpFallback.Value;
|
||||||
|
_line = _lineFallback.Value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_smtp = new SmtpOptions
|
||||||
|
{
|
||||||
|
Host = row.SmtpHost,
|
||||||
|
Port = row.SmtpPort,
|
||||||
|
UseSsl = row.SmtpUseSsl,
|
||||||
|
User = row.SmtpUser,
|
||||||
|
Password = row.SmtpPassword,
|
||||||
|
FromAddress = row.FromAddress,
|
||||||
|
FromName = row.FromName,
|
||||||
|
};
|
||||||
|
_line = new LineOptions
|
||||||
|
{
|
||||||
|
ChannelAccessToken = row.LineChannelAccessToken,
|
||||||
|
ChannelSecret = row.LineChannelSecret,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using ROLAC.API.Data;
|
||||||
|
using ROLAC.API.DTOs.Settings;
|
||||||
|
using ROLAC.API.Entities;
|
||||||
|
using ROLAC.API.Services.Notifications;
|
||||||
|
|
||||||
|
namespace ROLAC.API.Services;
|
||||||
|
|
||||||
|
public class SettingsService : ISettingsService
|
||||||
|
{
|
||||||
|
private readonly AppDbContext _db;
|
||||||
|
private readonly INotificationSettingsService _notificationSettings;
|
||||||
|
|
||||||
|
public SettingsService(AppDbContext db, INotificationSettingsService notificationSettings)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_notificationSettings = notificationSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<SiteSettingDto> GetSiteAsync()
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateSiteAsync();
|
||||||
|
return new SiteSettingDto
|
||||||
|
{
|
||||||
|
SiteTitle = s.SiteTitle,
|
||||||
|
SiteTitleZh = s.SiteTitleZh,
|
||||||
|
DefaultLanguage = s.DefaultLanguage,
|
||||||
|
TimeZone = s.TimeZone,
|
||||||
|
DateFormat = s.DateFormat,
|
||||||
|
Currency = s.Currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSiteAsync(UpdateSiteSettingRequest r)
|
||||||
|
{
|
||||||
|
var s = await GetOrCreateSiteAsync();
|
||||||
|
s.SiteTitle = r.SiteTitle;
|
||||||
|
s.SiteTitleZh = r.SiteTitleZh;
|
||||||
|
s.DefaultLanguage = r.DefaultLanguage;
|
||||||
|
s.TimeZone = r.TimeZone;
|
||||||
|
s.DateFormat = r.DateFormat;
|
||||||
|
s.Currency = r.Currency;
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<NotificationSettingDto> GetNotificationAsync()
|
||||||
|
{
|
||||||
|
var n = await GetOrCreateNotificationAsync();
|
||||||
|
return new NotificationSettingDto
|
||||||
|
{
|
||||||
|
EnableEmail = n.EnableEmail,
|
||||||
|
SmtpHost = n.SmtpHost,
|
||||||
|
SmtpPort = n.SmtpPort,
|
||||||
|
SmtpUseSsl = n.SmtpUseSsl,
|
||||||
|
SmtpUser = n.SmtpUser,
|
||||||
|
FromAddress = n.FromAddress,
|
||||||
|
FromName = n.FromName,
|
||||||
|
HasSmtpPassword = !string.IsNullOrEmpty(n.SmtpPassword),
|
||||||
|
EnableLine = n.EnableLine,
|
||||||
|
HasLineChannelAccessToken = !string.IsNullOrEmpty(n.LineChannelAccessToken),
|
||||||
|
HasLineChannelSecret = !string.IsNullOrEmpty(n.LineChannelSecret),
|
||||||
|
// WebhookUrl is filled by the controller (needs the request host).
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateNotificationAsync(UpdateNotificationSettingRequest r)
|
||||||
|
{
|
||||||
|
var n = await GetOrCreateNotificationAsync();
|
||||||
|
n.EnableEmail = r.EnableEmail;
|
||||||
|
n.SmtpHost = r.SmtpHost;
|
||||||
|
n.SmtpPort = r.SmtpPort;
|
||||||
|
n.SmtpUseSsl = r.SmtpUseSsl;
|
||||||
|
n.SmtpUser = r.SmtpUser;
|
||||||
|
n.FromAddress = r.FromAddress ?? "";
|
||||||
|
n.FromName = r.FromName ?? "";
|
||||||
|
n.EnableLine = r.EnableLine;
|
||||||
|
|
||||||
|
// Secrets are write-only: a blank value means "keep what's stored".
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.SmtpPassword))
|
||||||
|
n.SmtpPassword = r.SmtpPassword;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.LineChannelAccessToken))
|
||||||
|
n.LineChannelAccessToken = r.LineChannelAccessToken;
|
||||||
|
if (!string.IsNullOrWhiteSpace(r.LineChannelSecret))
|
||||||
|
n.LineChannelSecret = r.LineChannelSecret;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
// Drop the cached snapshot so the new values are used on the next send — no restart needed.
|
||||||
|
_notificationSettings.Reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SiteSetting> GetOrCreateSiteAsync()
|
||||||
|
{
|
||||||
|
var s = await _db.SiteSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
|
||||||
|
if (s is null)
|
||||||
|
{
|
||||||
|
s = new SiteSetting { SiteTitle = "Church" };
|
||||||
|
_db.SiteSettings.Add(s);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<NotificationSetting> GetOrCreateNotificationAsync()
|
||||||
|
{
|
||||||
|
var n = await _db.NotificationSettings.OrderBy(x => x.Id).FirstOrDefaultAsync();
|
||||||
|
if (n is null)
|
||||||
|
{
|
||||||
|
n = new NotificationSetting();
|
||||||
|
_db.NotificationSettings.Add(n);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export const PermissionModules = {
|
|||||||
Permissions: 'Permissions',
|
Permissions: 'Permissions',
|
||||||
SystemLogs: 'SystemLogs',
|
SystemLogs: 'SystemLogs',
|
||||||
AuditLogs: 'AuditLogs',
|
AuditLogs: 'AuditLogs',
|
||||||
|
Settings: 'Settings',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** A required permission, used in route data and the *appHasPermission directive. */
|
/** A required permission, used in route data and the *appHasPermission directive. */
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export interface CheckDetailDto extends CheckListItemDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChurchProfileDto {
|
export interface ChurchProfileDto {
|
||||||
id: number; name: string; address: string | null; city: string | null;
|
id: number; name: string; nameZh: string | null; phone: string | null;
|
||||||
|
email: string | null; website: string | null; address: string | null; city: string | null;
|
||||||
state: string | null; zipCode: string | null; bankName: string | null;
|
state: string | null; zipCode: string | null; bankName: string | null;
|
||||||
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-46
@@ -1,49 +1,86 @@
|
|||||||
<div class="page">
|
<div class="page">
|
||||||
<div *ngIf="model" class="max-w-3xl">
|
<kendo-tabstrip>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
<!-- ── Tab 1: Church Info (existing ChurchProfile permission) ──────────── -->
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<kendo-tabstrip-tab title="Church Info / 教會資料" [selected]="true">
|
||||||
Church Name / 教會名稱
|
<ng-template kendoTabContent>
|
||||||
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
|
<div *ngIf="model" class="max-w-3xl pt-4">
|
||||||
</label>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
<label class="flex flex-col gap-1 md:col-span-2">
|
<label class="flex flex-col gap-1">
|
||||||
Address / 地址
|
Church Name / 教會名稱
|
||||||
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.name"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex flex-col gap-1">
|
<label class="flex flex-col gap-1">
|
||||||
City / 城市
|
Church Name (ZH) / 教會名稱(中)
|
||||||
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
|
<kendo-textbox [(ngModel)]="model.nameZh"></kendo-textbox>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<label class="flex flex-col gap-1">
|
||||||
<label class="flex flex-col gap-1">
|
Phone / 電話
|
||||||
State / 州
|
<kendo-textbox [(ngModel)]="model.phone"></kendo-textbox>
|
||||||
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
|
</label>
|
||||||
</label>
|
<label class="flex flex-col gap-1">
|
||||||
<label class="flex flex-col gap-1">
|
Email / 電子郵件
|
||||||
Zip / 郵遞區號
|
<kendo-textbox [(ngModel)]="model.email"></kendo-textbox>
|
||||||
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
|
</label>
|
||||||
</label>
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
</div>
|
Website / 網站
|
||||||
<label class="flex flex-col gap-1">
|
<kendo-textbox [(ngModel)]="model.website" placeholder="https://"></kendo-textbox>
|
||||||
Bank Name / 銀行名稱
|
</label>
|
||||||
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
|
<label class="flex flex-col gap-1 md:col-span-2">
|
||||||
</label>
|
Address / 地址
|
||||||
<label class="flex flex-col gap-1">
|
<kendo-textbox [(ngModel)]="model.address"></kendo-textbox>
|
||||||
Bank Account # / 銀行帳號
|
</label>
|
||||||
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
|
<label class="flex flex-col gap-1">
|
||||||
</label>
|
City / 城市
|
||||||
<label class="flex flex-col gap-1">
|
<kendo-textbox [(ngModel)]="model.city"></kendo-textbox>
|
||||||
Routing # / 路由號碼
|
</label>
|
||||||
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
<div class="grid grid-cols-2 gap-2">
|
||||||
</label>
|
<label class="flex flex-col gap-1">
|
||||||
<label class="flex flex-col gap-1">
|
State / 州
|
||||||
Next Check # / 下一張支票號碼
|
<kendo-textbox [(ngModel)]="model.state"></kendo-textbox>
|
||||||
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
</label>
|
||||||
</label>
|
<label class="flex flex-col gap-1">
|
||||||
</div>
|
Zip / 郵遞區號
|
||||||
|
<kendo-textbox [(ngModel)]="model.zipCode"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Bank Name / 銀行名稱
|
||||||
|
<kendo-textbox [(ngModel)]="model.bankName"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Bank Account # / 銀行帳號
|
||||||
|
<kendo-textbox [(ngModel)]="model.bankAccountNumber"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Routing # / 路由號碼
|
||||||
|
<kendo-textbox [(ngModel)]="model.bankRoutingNumber"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Next Check # / 下一張支票號碼
|
||||||
|
<kendo-numerictextbox [(ngModel)]="model.nextCheckNumber" [min]="1" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 mt-4">
|
<div class="flex items-center gap-3 mt-4">
|
||||||
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||||
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
|
||||||
|
<!-- ── Tab 2: Site Settings (Settings permission) ─────────────────────── -->
|
||||||
|
<kendo-tabstrip-tab title="Site Settings / 網站設定" *appHasPermission="settingsPermission">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<app-site-settings-tab></app-site-settings-tab>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
|
||||||
|
<!-- ── Tab 3: Notification Settings (Settings permission) ─────────────── -->
|
||||||
|
<kendo-tabstrip-tab title="Notifications / 通知設定" *appHasPermission="settingsPermission">
|
||||||
|
<ng-template kendoTabContent>
|
||||||
|
<app-notification-settings-tab></app-notification-settings-tab>
|
||||||
|
</ng-template>
|
||||||
|
</kendo-tabstrip-tab>
|
||||||
|
</kendo-tabstrip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+12
-1
@@ -3,13 +3,21 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
import { InputsModule } from '@progress/kendo-angular-inputs';
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { LayoutModule } from '@progress/kendo-angular-layout';
|
||||||
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
import { DisbursementApiService } from '../../services/disbursement-api.service';
|
||||||
import { ChurchProfileDto } from '../../models/disbursement.model';
|
import { ChurchProfileDto } from '../../models/disbursement.model';
|
||||||
|
import { HasPermissionDirective } from '../../../../core/directives/has-permission.directive';
|
||||||
|
import { PermissionModules } from '../../../../core/models/permission.model';
|
||||||
|
import { SiteSettingsTabComponent } from '../../../settings/components/site-settings-tab/site-settings-tab.component';
|
||||||
|
import { NotificationSettingsTabComponent } from '../../../settings/components/notification-settings-tab/notification-settings-tab.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-church-profile-page',
|
selector: 'app-church-profile-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
|
imports: [
|
||||||
|
CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule,
|
||||||
|
HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent,
|
||||||
|
],
|
||||||
templateUrl: './church-profile-page.component.html',
|
templateUrl: './church-profile-page.component.html',
|
||||||
})
|
})
|
||||||
export class ChurchProfilePageComponent implements OnInit {
|
export class ChurchProfilePageComponent implements OnInit {
|
||||||
@@ -17,6 +25,9 @@ export class ChurchProfilePageComponent implements OnInit {
|
|||||||
saving = false;
|
saving = false;
|
||||||
savedMsg = '';
|
savedMsg = '';
|
||||||
|
|
||||||
|
/** Settings module gates the Site / Notification tabs. */
|
||||||
|
readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const };
|
||||||
|
|
||||||
constructor(private api: DisbursementApiService) {}
|
constructor(private api: DisbursementApiService) {}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
<div *ngIf="model" class="max-w-3xl pt-4 flex flex-col gap-6">
|
||||||
|
|
||||||
|
<!-- ── Email (SMTP) ─────────────────────────────────────────────────────── -->
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-semibold">Email (SMTP) / 電子郵件</h3>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
Enabled / 啟用
|
||||||
|
<kendo-switch [(ngModel)]="model.enableEmail"></kendo-switch>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
SMTP Host / 主機
|
||||||
|
<kendo-textbox [(ngModel)]="model.smtpHost" placeholder="smtp.example.com"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Port / 連接埠
|
||||||
|
<kendo-numerictextbox [(ngModel)]="model.smtpPort" [min]="0" [max]="65535" [decimals]="0" format="#"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Use SSL / 加密
|
||||||
|
<kendo-switch [(ngModel)]="model.smtpUseSsl"></kendo-switch>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
SMTP User / 帳號
|
||||||
|
<kendo-textbox [(ngModel)]="model.smtpUser"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
SMTP Password / 密碼
|
||||||
|
<kendo-textbox [(ngModel)]="smtpPassword" type="password"
|
||||||
|
[placeholder]="model.hasSmtpPassword ? '•••••• stored (blank = keep) / 已設定' : ''"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
From Address / 寄件地址
|
||||||
|
<kendo-textbox [(ngModel)]="model.fromAddress" placeholder="noreply@church.org"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
From Name / 寄件人名稱
|
||||||
|
<kendo-textbox [(ngModel)]="model.fromName"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="flex flex-col gap-1 grow max-w-xs">
|
||||||
|
<span class="text-sm">Test recipient (blank = you) / 測試收件人</span>
|
||||||
|
<kendo-textbox [(ngModel)]="testEmailTo" placeholder="you@example.com"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<button kendoButton [disabled]="testingEmail" (click)="sendTestEmail()">Send test email / 寄送測試</button>
|
||||||
|
<span class="text-sm" [style.color]="testEmailMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testEmailMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<hr class="border-gray-200" />
|
||||||
|
|
||||||
|
<!-- ── Line ─────────────────────────────────────────────────────────────── -->
|
||||||
|
<section class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-base font-semibold">Line / Line 通知</h3>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
Enabled / 啟用
|
||||||
|
<kendo-switch [(ngModel)]="model.enableLine"></kendo-switch>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Channel Access Token / 頻道存取權杖
|
||||||
|
<kendo-textbox [(ngModel)]="lineToken" type="password"
|
||||||
|
[placeholder]="model.hasLineChannelAccessToken ? '•••••• stored (blank = keep) / 已設定' : ''"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Channel Secret / 頻道密鑰
|
||||||
|
<kendo-textbox [(ngModel)]="lineSecret" type="password"
|
||||||
|
[placeholder]="model.hasLineChannelSecret ? '•••••• stored (blank = keep) / 已設定' : ''"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Webhook URL (register in Line console) / Webhook 網址
|
||||||
|
<kendo-textbox [ngModel]="model.webhookUrl" [readonly]="true"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-end gap-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">Test member ID / 會員編號</span>
|
||||||
|
<kendo-numerictextbox [(ngModel)]="testLineMemberId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
<span class="text-sm">or Group ID / 群組編號</span>
|
||||||
|
<kendo-numerictextbox [(ngModel)]="testLineGroupId" [decimals]="0" format="#" [min]="1" class="w-40"></kendo-numerictextbox>
|
||||||
|
</label>
|
||||||
|
<button kendoButton [disabled]="testingLine" (click)="sendTestLine()">Send test Line / 寄送測試</button>
|
||||||
|
<span class="text-sm" [style.color]="testLineMsg.startsWith('Sent') ? '#065f46' : '#b91c1c'">{{ testLineMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||||
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { SettingsApiService } from '../../services/settings-api.service';
|
||||||
|
import {
|
||||||
|
NotificationSettingDto, UpdateNotificationSettingRequest, NotificationResult,
|
||||||
|
} from '../../models/settings.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-notification-settings-tab',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule],
|
||||||
|
templateUrl: './notification-settings-tab.component.html',
|
||||||
|
})
|
||||||
|
export class NotificationSettingsTabComponent implements OnInit {
|
||||||
|
model: NotificationSettingDto | null = null;
|
||||||
|
saving = false;
|
||||||
|
savedMsg = '';
|
||||||
|
|
||||||
|
// Write-only secret inputs — blank means "keep the stored value".
|
||||||
|
smtpPassword = '';
|
||||||
|
lineToken = '';
|
||||||
|
lineSecret = '';
|
||||||
|
|
||||||
|
// Test-send state.
|
||||||
|
testEmailTo = '';
|
||||||
|
testEmailMsg = '';
|
||||||
|
testingEmail = false;
|
||||||
|
|
||||||
|
testLineMemberId: number | null = null;
|
||||||
|
testLineGroupId: number | null = null;
|
||||||
|
testLineMsg = '';
|
||||||
|
testingLine = false;
|
||||||
|
|
||||||
|
constructor(private api: SettingsApiService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): void {
|
||||||
|
this.api.getNotification().subscribe(n => (this.model = n));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
if (!this.model || this.saving) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.savedMsg = '';
|
||||||
|
const m = this.model;
|
||||||
|
const request: UpdateNotificationSettingRequest = {
|
||||||
|
enableEmail: m.enableEmail,
|
||||||
|
smtpHost: m.smtpHost,
|
||||||
|
smtpPort: m.smtpPort,
|
||||||
|
smtpUseSsl: m.smtpUseSsl,
|
||||||
|
smtpUser: m.smtpUser,
|
||||||
|
fromAddress: m.fromAddress,
|
||||||
|
fromName: m.fromName,
|
||||||
|
smtpPassword: this.smtpPassword || null,
|
||||||
|
enableLine: m.enableLine,
|
||||||
|
lineChannelAccessToken: this.lineToken || null,
|
||||||
|
lineChannelSecret: this.lineSecret || null,
|
||||||
|
};
|
||||||
|
this.api.updateNotification(request).subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.saving = false;
|
||||||
|
this.savedMsg = 'Saved / 已儲存';
|
||||||
|
// Clear secret inputs and refresh the "configured" flags.
|
||||||
|
this.smtpPassword = this.lineToken = this.lineSecret = '';
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
error: () => { this.saving = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTestEmail(): void {
|
||||||
|
if (this.testingEmail) return;
|
||||||
|
this.testingEmail = true;
|
||||||
|
this.testEmailMsg = '';
|
||||||
|
this.api.testEmail({ toAddress: this.testEmailTo || null }).subscribe({
|
||||||
|
next: result => { this.testingEmail = false; this.testEmailMsg = this.describe(result); },
|
||||||
|
error: err => { this.testingEmail = false; this.testEmailMsg = this.errorText(err); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTestLine(): void {
|
||||||
|
if (this.testingLine) return;
|
||||||
|
this.testingLine = true;
|
||||||
|
this.testLineMsg = '';
|
||||||
|
this.api.testLine({ memberId: this.testLineMemberId, groupId: this.testLineGroupId }).subscribe({
|
||||||
|
next: result => { this.testingLine = false; this.testLineMsg = this.describe(result); },
|
||||||
|
error: err => { this.testingLine = false; this.testLineMsg = this.errorText(err); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private describe(result: NotificationResult): string {
|
||||||
|
if (result.failedCount > 0)
|
||||||
|
return `Failed / 失敗:${result.failures.map(f => f.error).join('; ')}`;
|
||||||
|
return result.sentCount > 0 ? 'Sent ✓ / 已發送' : 'Nothing sent / 未發送';
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorText(err: { error?: { message?: string } }): string {
|
||||||
|
return err?.error?.message ?? 'Failed / 失敗';
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
<div *ngIf="model" class="max-w-3xl pt-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Site Title (EN) / 網站名稱
|
||||||
|
<kendo-textbox [(ngModel)]="model.siteTitle"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Site Title (ZH) / 網站名稱(中)
|
||||||
|
<kendo-textbox [(ngModel)]="model.siteTitleZh"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Default Language / 預設語言
|
||||||
|
<kendo-dropdownlist
|
||||||
|
[data]="languages" textField="text" valueField="value" [valuePrimitive]="true"
|
||||||
|
[(ngModel)]="model.defaultLanguage"></kendo-dropdownlist>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Time Zone / 時區
|
||||||
|
<kendo-textbox [(ngModel)]="model.timeZone" placeholder="America/Los_Angeles"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Date Format / 日期格式
|
||||||
|
<kendo-textbox [(ngModel)]="model.dateFormat" placeholder="yyyy-MM-dd"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1">
|
||||||
|
Currency / 貨幣
|
||||||
|
<kendo-textbox [(ngModel)]="model.currency" placeholder="USD"></kendo-textbox>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 mt-4">
|
||||||
|
<button kendoButton themeColor="primary" [disabled]="saving" (click)="save()">Save / 儲存</button>
|
||||||
|
<span class="text-sm" style="color:#065f46;">{{ savedMsg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { ButtonsModule } from '@progress/kendo-angular-buttons';
|
||||||
|
import { InputsModule } from '@progress/kendo-angular-inputs';
|
||||||
|
import { DropDownsModule } from '@progress/kendo-angular-dropdowns';
|
||||||
|
import { SettingsApiService } from '../../services/settings-api.service';
|
||||||
|
import { SiteSettingDto } from '../../models/settings.model';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-site-settings-tab',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, FormsModule, ButtonsModule, InputsModule, DropDownsModule],
|
||||||
|
templateUrl: './site-settings-tab.component.html',
|
||||||
|
})
|
||||||
|
export class SiteSettingsTabComponent implements OnInit {
|
||||||
|
model: SiteSettingDto | null = null;
|
||||||
|
saving = false;
|
||||||
|
savedMsg = '';
|
||||||
|
|
||||||
|
readonly languages = [
|
||||||
|
{ text: 'English', value: 'en' },
|
||||||
|
{ text: '中文 Chinese', value: 'zh' },
|
||||||
|
];
|
||||||
|
|
||||||
|
constructor(private api: SettingsApiService) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.api.getSite().subscribe(s => (this.model = s));
|
||||||
|
}
|
||||||
|
|
||||||
|
save(): void {
|
||||||
|
if (!this.model || this.saving) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.savedMsg = '';
|
||||||
|
this.api.updateSite(this.model).subscribe({
|
||||||
|
next: () => { this.saving = false; this.savedMsg = 'Saved / 已儲存'; },
|
||||||
|
// Errors surface globally via httpErrorInterceptor.
|
||||||
|
error: () => { this.saving = false; },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Mirrors ROLAC.API.DTOs.Settings — site + notification settings edited from the
|
||||||
|
// Church Profile tabbed page (Settings permission module).
|
||||||
|
|
||||||
|
export interface SiteSettingDto {
|
||||||
|
siteTitle: string;
|
||||||
|
siteTitleZh: string | null;
|
||||||
|
defaultLanguage: string; // 'en' | 'zh'
|
||||||
|
timeZone: string;
|
||||||
|
dateFormat: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateSiteSettingRequest = SiteSettingDto;
|
||||||
|
|
||||||
|
export interface NotificationSettingDto {
|
||||||
|
enableEmail: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUseSsl: boolean;
|
||||||
|
smtpUser: string;
|
||||||
|
fromAddress: string;
|
||||||
|
fromName: string;
|
||||||
|
/** True when a password is stored — secrets themselves are never returned. */
|
||||||
|
hasSmtpPassword: boolean;
|
||||||
|
|
||||||
|
enableLine: boolean;
|
||||||
|
hasLineChannelAccessToken: boolean;
|
||||||
|
hasLineChannelSecret: boolean;
|
||||||
|
|
||||||
|
/** Read-only webhook URL to register in the Line console. */
|
||||||
|
webhookUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateNotificationSettingRequest {
|
||||||
|
enableEmail: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUseSsl: boolean;
|
||||||
|
smtpUser: string;
|
||||||
|
fromAddress: string | null;
|
||||||
|
fromName: string | null;
|
||||||
|
/** Leave blank/omit to keep the stored password. */
|
||||||
|
smtpPassword?: string | null;
|
||||||
|
|
||||||
|
enableLine: boolean;
|
||||||
|
/** Leave blank/omit to keep the stored token. */
|
||||||
|
lineChannelAccessToken?: string | null;
|
||||||
|
/** Leave blank/omit to keep the stored secret. */
|
||||||
|
lineChannelSecret?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestEmailRequest {
|
||||||
|
toAddress?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestLineRequest {
|
||||||
|
memberId?: number | null;
|
||||||
|
groupId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors ROLAC.API NotificationResult. */
|
||||||
|
export interface NotificationResult {
|
||||||
|
sentCount: number;
|
||||||
|
failedCount: number;
|
||||||
|
failures: { target: string; error: string }[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { ApiConfigService } from '../../../core/services/api-config.service';
|
||||||
|
import {
|
||||||
|
SiteSettingDto, UpdateSiteSettingRequest,
|
||||||
|
NotificationSettingDto, UpdateNotificationSettingRequest,
|
||||||
|
TestEmailRequest, TestLineRequest, NotificationResult,
|
||||||
|
} from '../models/settings.model';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SettingsApiService {
|
||||||
|
private readonly endpoint: string;
|
||||||
|
|
||||||
|
constructor(private http: HttpClient, apiConfig: ApiConfigService) {
|
||||||
|
this.endpoint = apiConfig.getApiUrl('settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
getSite(): Observable<SiteSettingDto> {
|
||||||
|
return this.http.get<SiteSettingDto>(`${this.endpoint}/site`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSite(request: UpdateSiteSettingRequest): Observable<void> {
|
||||||
|
return this.http.put<void>(`${this.endpoint}/site`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNotification(): Observable<NotificationSettingDto> {
|
||||||
|
return this.http.get<NotificationSettingDto>(`${this.endpoint}/notification`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNotification(request: UpdateNotificationSettingRequest): Observable<void> {
|
||||||
|
return this.http.put<void>(`${this.endpoint}/notification`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
testEmail(request: TestEmailRequest): Observable<NotificationResult> {
|
||||||
|
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-email`, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
testLine(request: TestLineRequest): Observable<NotificationResult> {
|
||||||
|
return this.http.post<NotificationResult>(`${this.endpoint}/notification/test-line`, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user