diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs index 05692c5..801fa63 100644 --- a/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs +++ b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Text.Json; -using Microsoft.Extensions.Options; using ROLAC.API.Services.Notifications; using Xunit; @@ -8,6 +7,14 @@ namespace ROLAC.API.Tests.Services.Notifications; 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. private sealed class CapturingHandler : HttpMessageHandler { @@ -28,8 +35,7 @@ public class LineMessageChannelTests private static LineMessageChannel BuildChannel(CapturingHandler handler) { var http = new HttpClient(handler); - var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" }); - return new LineMessageChannel(http, options); + return new LineMessageChannel(http, new StubSettings()); } [Fact] diff --git a/API/ROLAC.API/Authorization/Modules.cs b/API/ROLAC.API/Authorization/Modules.cs index c6582bd..0202979 100644 --- a/API/ROLAC.API/Authorization/Modules.cs +++ b/API/ROLAC.API/Authorization/Modules.cs @@ -23,6 +23,7 @@ public static class Modules public const string Permissions = "Permissions"; public const string SystemLogs = "SystemLogs"; public const string AuditLogs = "AuditLogs"; + public const string Settings = "Settings"; /// All modules, in display order — drives the admin matrix UI. public static readonly IReadOnlyList All = @@ -43,6 +44,7 @@ public static class Modules Permissions, SystemLogs, AuditLogs, + Settings, ]; public static bool IsValid(string module) => All.Contains(module); diff --git a/API/ROLAC.API/Controllers/LineWebhookController.cs b/API/ROLAC.API/Controllers/LineWebhookController.cs index 5a144a3..0854080 100644 --- a/API/ROLAC.API/Controllers/LineWebhookController.cs +++ b/API/ROLAC.API/Controllers/LineWebhookController.cs @@ -2,7 +2,6 @@ using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using ROLAC.API.DTOs.Notifications; using ROLAC.API.Services.Notifications; @@ -22,14 +21,14 @@ public sealed class LineWebhookController : ControllerBase private readonly ILineNotificationService _line; private readonly IMessageChannel _channel; - private readonly LineOptions _options; + private readonly INotificationSettingsService _settings; public LineWebhookController( - ILineNotificationService line, IMessageChannel channel, IOptions options) + ILineNotificationService line, IMessageChannel channel, INotificationSettingsService settings) { _line = line; _channel = channel; - _options = options.Value; + _settings = settings; } [HttpPost("webhook")] @@ -40,7 +39,7 @@ public sealed class LineWebhookController : ControllerBase var rawBody = await reader.ReadToEndAsync(ct); 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(); var payload = JsonSerializer.Deserialize(rawBody, JsonOpts); diff --git a/API/ROLAC.API/Controllers/SettingsController.cs b/API/ROLAC.API/Controllers/SettingsController.cs new file mode 100644 index 0000000..21ab3f7 --- /dev/null +++ b/API/ROLAC.API/Controllers/SettingsController.cs @@ -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; + +/// +/// Site-wide and notification (SMTP/Line) settings, surfaced by the Church Profile → Site / +/// Notification tabs. Gated by the Settings permission module (super_admin bypasses). +/// +[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 GetSite() => Ok(await _settings.GetSiteAsync()); + + [HttpPut("site")] + [HasPermission(Modules.Settings, PermissionActions.Write)] + public async Task UpdateSite([FromBody] UpdateSiteSettingRequest request) + { + await _settings.UpdateSiteAsync(request); + return NoContent(); + } + + // ── Notification settings ────────────────────────────────────────────────── + + [HttpGet("notification")] + [HasPermission(Modules.Settings, PermissionActions.Read)] + public async Task 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 UpdateNotification([FromBody] UpdateNotificationSettingRequest request) + { + await _settings.UpdateNotificationAsync(request); + return NoContent(); + } + + [HttpPost("notification/test-email")] + [HasPermission(Modules.Settings, PermissionActions.Write)] + public async Task 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(), + Addresses: new[] { to }, + Subject: "ROLAC test email / 測試郵件", + HtmlBody: "

This is a test email from ROLAC notification settings.

" + + "

這是來自 ROLAC 通知設定的測試郵件。

", + SentByUserId: _currentUser.UserIdOrSystem), ct); + + return Ok(result); + } + + [HttpPost("notification/test-line")] + [HasPermission(Modules.Settings, PermissionActions.Write)] + public async Task 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(), + groupIds: request.GroupId is { } g ? new[] { g } : Array.Empty(), + sentByUserId: _currentUser.UserIdOrSystem, + ct); + + return Ok(result); + } +} diff --git a/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs b/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs index 2b7f472..8a18e3c 100644 --- a/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs +++ b/API/ROLAC.API/DTOs/Disbursement/ChurchProfileDtos.cs @@ -5,6 +5,10 @@ public class ChurchProfileDto { public int Id { 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? City { get; set; } public string? State { get; set; } @@ -18,6 +22,10 @@ public class ChurchProfileDto public class UpdateChurchProfileRequest { [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(100)] public string? City { get; set; } [MaxLength(50)] public string? State { get; set; } diff --git a/API/ROLAC.API/DTOs/Settings/SettingsDtos.cs b/API/ROLAC.API/DTOs/Settings/SettingsDtos.cs new file mode 100644 index 0000000..6b410d2 --- /dev/null +++ b/API/ROLAC.API/DTOs/Settings/SettingsDtos.cs @@ -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; } + + /// Read-only webhook URL to register in the Line console (derived from the request). + 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; } + /// Blank = keep the stored password unchanged. + [MaxLength(500)] public string? SmtpPassword { get; set; } + + public bool EnableLine { get; set; } + /// Blank = keep the stored token unchanged. + [MaxLength(500)] public string? LineChannelAccessToken { get; set; } + /// Blank = keep the stored secret unchanged. + [MaxLength(200)] public string? LineChannelSecret { get; set; } +} + +// ── Test-send requests ───────────────────────────────────────────────────── + +public class TestEmailRequest +{ + /// Optional override; defaults to the current user's email when omitted. + [MaxLength(200), EmailAddress] public string? ToAddress { get; set; } +} + +public class TestLineRequest +{ + public int? MemberId { get; set; } + public int? GroupId { get; set; } +} diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index af2cd67..e8e7338 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -32,6 +32,9 @@ public class AppDbContext : IdentityDbContext public DbSet MessagingGroups => Set(); public DbSet NotificationLogs => Set(); + public DbSet SiteSettings => Set(); + public DbSet NotificationSettings => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -245,12 +248,43 @@ public class AppDbContext : IdentityDbContext entity.Property(e => e.BankName).HasMaxLength(200); entity.Property(e => e.BankAccountNumber).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.UpdatedBy).HasMaxLength(450); // Optimistic-concurrency token for safe check-number allocation. entity.Property(e => e.xmin).IsRowVersion(); }); + // ── SiteSetting (singleton presentation/locale settings) ───────────── + builder.Entity(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(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) ───────────────────────────────────────────── builder.Entity(entity => { diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index 5852dde..31c040c 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -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(); + } + } + /// /// Seeds roles and (in Development) the default admin account. /// Called once on application startup after migrations have been applied. @@ -217,6 +261,7 @@ public static class DbSeeder var roleManager = services.GetRequiredService>(); var userManager = services.GetRequiredService>(); var env = services.GetRequiredService(); + var config = services.GetRequiredService(); await SeedRolesAsync(roleManager); @@ -226,6 +271,8 @@ public static class DbSeeder await SeedMinistriesAsync(db); await SeedExpenseCategoriesAsync(db); await SeedChurchProfileAsync(db); + await SeedSiteSettingAsync(db); + await SeedNotificationSettingAsync(db, config); if (env.IsDevelopment()) await SeedAdminUserAsync(userManager); diff --git a/API/ROLAC.API/Entities/ChurchProfile.cs b/API/ROLAC.API/Entities/ChurchProfile.cs index 088fabc..242e09d 100644 --- a/API/ROLAC.API/Entities/ChurchProfile.cs +++ b/API/ROLAC.API/Entities/ChurchProfile.cs @@ -9,6 +9,10 @@ public class ChurchProfile : AuditableEntity, IAuditable { public int Id { get; set; } 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? City { get; set; } public string? State { get; set; } diff --git a/API/ROLAC.API/Entities/NotificationSetting.cs b/API/ROLAC.API/Entities/NotificationSetting.cs new file mode 100644 index 0000000..12bb96e --- /dev/null +++ b/API/ROLAC.API/Entities/NotificationSetting.cs @@ -0,0 +1,32 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// 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 INotificationSettingsService so +/// edits apply without restarting the API. +/// +/// Secrets (, , +/// ) are stored plaintext and protected by RBAC (the Settings +/// module / super_admin) per the project decision for this small single-VM internal app. +/// +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; } = ""; +} diff --git a/API/ROLAC.API/Entities/SiteSetting.cs b/API/ROLAC.API/Entities/SiteSetting.cs new file mode 100644 index 0000000..09a26d3 --- /dev/null +++ b/API/ROLAC.API/Entities/SiteSetting.cs @@ -0,0 +1,18 @@ +using ROLAC.API.Entities.Base; +namespace ROLAC.API.Entities; + +/// +/// Singleton (Id == 1) holding site-wide presentation and locale settings, edited from the +/// Church Profile → Site Settings tab (gated by the Settings permission module). +/// Seeded with sensible defaults on startup. +/// +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"; +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index bd00e1f..e5f3945 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -463,14 +463,26 @@ namespace ROLAC.API.Migrations .HasMaxLength(450) .HasColumnType("character varying(450)"); + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("NameZh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("NextCheckNumber") .HasColumnType("integer"); + b.Property("Phone") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + b.Property("State") .HasMaxLength(50) .HasColumnType("character varying(50)"); @@ -483,6 +495,10 @@ namespace ROLAC.API.Migrations .HasMaxLength(450) .HasColumnType("character varying(450)"); + b.Property("Website") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + b.Property("ZipCode") .HasMaxLength(20) .HasColumnType("character varying(20)"); @@ -1323,6 +1339,82 @@ namespace ROLAC.API.Migrations b.ToTable("MonthlyStatements"); }); + modelBuilder.Entity("ROLAC.API.Entities.NotificationSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("EnableEmail") + .HasColumnType("boolean"); + + b.Property("EnableLine") + .HasColumnType("boolean"); + + b.Property("FromAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FromName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("LineChannelAccessToken") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("LineChannelSecret") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SmtpHost") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SmtpPassword") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SmtpPort") + .HasColumnType("integer"); + + b.Property("SmtpUseSsl") + .HasColumnType("boolean"); + + b.Property("SmtpUser") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("NotificationSettings"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b => { b.Property("Id") @@ -1653,6 +1745,64 @@ namespace ROLAC.API.Migrations b.ToTable("RolePermissions"); }); + modelBuilder.Entity("ROLAC.API.Entities.SiteSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Currency") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("DateFormat") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DefaultLanguage") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("SiteTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SiteTitleZh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TimeZone") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("SiteSettings"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("ROLAC.API.Entities.AppRole", null) diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 7193b10..f0c720e 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -155,14 +155,19 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ── 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(config.GetSection("Smtp")); builder.Services.Configure(config.GetSection("Line")); +builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped +/// 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. +/// +public interface ISettingsService +{ + Task GetSiteAsync(); + Task UpdateSiteAsync(UpdateSiteSettingRequest request); + + Task GetNotificationAsync(); + Task UpdateNotificationAsync(UpdateNotificationSettingRequest request); +} diff --git a/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs b/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs index 14692d4..8c2b0bb 100644 --- a/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs +++ b/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs @@ -1,6 +1,5 @@ using System.Net.Http.Headers; using System.Net.Http.Json; -using Microsoft.Extensions.Options; 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 readonly HttpClient _http; - private readonly LineOptions _options; + private readonly INotificationSettingsService _settings; - public LineMessageChannel(HttpClient http, IOptions options) + public LineMessageChannel(HttpClient http, INotificationSettingsService settings) { _http = http; - _options = options.Value; + _settings = settings; } public Task PushToUserAsync(string externalId, string text, CancellationToken ct = default) @@ -36,7 +35,8 @@ public sealed class LineMessageChannel : IMessageChannel { 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); if (response.IsSuccessStatusCode) return new MessageSendResult(true, null); diff --git a/API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs b/API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs index b09e22c..8896584 100644 --- a/API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs +++ b/API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs @@ -1,21 +1,22 @@ using MailKit.Net.Smtp; using MailKit.Security; -using Microsoft.Extensions.Options; using MimeKit; namespace ROLAC.API.Services.Notifications; -/// Sends a single email via MailKit using the configured SMTP server. +/// Sends a single email via MailKit using the current (DB-backed) SMTP settings. public sealed class MailKitSmtpDispatcher : ISmtpDispatcher { - private readonly SmtpOptions _options; + private readonly INotificationSettingsService _settings; - public MailKitSmtpDispatcher(IOptions options) => _options = options.Value; + public MailKitSmtpDispatcher(INotificationSettingsService settings) => _settings = settings; public async Task SendAsync(OutboundEmail email, CancellationToken ct = default) { + var options = _settings.GetSmtp(); + 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.Subject = email.Subject; @@ -28,10 +29,10 @@ public sealed class MailKitSmtpDispatcher : ISmtpDispatcher message.Body = builder.ToMessageBody(); using var client = new SmtpClient(); - var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; - await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct); - if (!string.IsNullOrEmpty(_options.User)) - await client.AuthenticateAsync(_options.User, _options.Password, ct); + var socketOptions = options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; + await client.ConnectAsync(options.Host, options.Port, socketOptions, ct); + if (!string.IsNullOrEmpty(options.User)) + await client.AuthenticateAsync(options.User, options.Password, ct); await client.SendAsync(message, ct); await client.DisconnectAsync(true, ct); } diff --git a/API/ROLAC.API/Services/Notifications/NotificationSettingsService.cs b/API/ROLAC.API/Services/Notifications/NotificationSettingsService.cs new file mode 100644 index 0000000..49b0b79 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/NotificationSettingsService.cs @@ -0,0 +1,98 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using ROLAC.API.Data; + +namespace ROLAC.API.Services.Notifications; + +/// +/// Supplies the current SMTP/Line settings from the NotificationSetting 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 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. +/// +public interface INotificationSettingsService +{ + SmtpOptions GetSmtp(); + LineOptions GetLine(); + void Reload(); +} + +public sealed class NotificationSettingsService : INotificationSettingsService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly IOptions _smtpFallback; + private readonly IOptions _lineFallback; + private readonly object _gate = new(); + + private SmtpOptions? _smtp; + private LineOptions? _line; + + public NotificationSettingsService( + IServiceScopeFactory scopeFactory, + IOptions smtpFallback, + IOptions 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(); + 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, + }; + } + } +} diff --git a/API/ROLAC.API/Services/SettingsService.cs b/API/ROLAC.API/Services/SettingsService.cs new file mode 100644 index 0000000..b8f5fe0 --- /dev/null +++ b/API/ROLAC.API/Services/SettingsService.cs @@ -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 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 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 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 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; + } +} diff --git a/APP/src/app/core/models/permission.model.ts b/APP/src/app/core/models/permission.model.ts index b2f6fa4..982c539 100644 --- a/APP/src/app/core/models/permission.model.ts +++ b/APP/src/app/core/models/permission.model.ts @@ -31,6 +31,7 @@ export const PermissionModules = { Permissions: 'Permissions', SystemLogs: 'SystemLogs', AuditLogs: 'AuditLogs', + Settings: 'Settings', } as const; /** A required permission, used in route data and the *appHasPermission directive. */ diff --git a/APP/src/app/features/disbursement/models/disbursement.model.ts b/APP/src/app/features/disbursement/models/disbursement.model.ts index 207044f..060fe10 100644 --- a/APP/src/app/features/disbursement/models/disbursement.model.ts +++ b/APP/src/app/features/disbursement/models/disbursement.model.ts @@ -45,7 +45,8 @@ export interface CheckDetailDto extends CheckListItemDto { } 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; bankAccountNumber: string | null; bankRoutingNumber: string | null; nextCheckNumber: number; } diff --git a/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html index ae3686c..e7ce993 100644 --- a/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html +++ b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.html @@ -1,49 +1,86 @@
-
-
- - - -
- - -
- - - - -
+ + + + +
+
+ + + + + + + +
+ + +
+ + + + +
-
- - {{ savedMsg }} -
-
+
+ + {{ savedMsg }} +
+
+ + + + + + + + + + + + + + + + +
diff --git a/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts index abfcb65..d156251 100644 --- a/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts +++ b/APP/src/app/features/disbursement/pages/church-profile-page/church-profile-page.component.ts @@ -3,13 +3,21 @@ 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 { LayoutModule } from '@progress/kendo-angular-layout'; import { DisbursementApiService } from '../../services/disbursement-api.service'; 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({ selector: 'app-church-profile-page', standalone: true, - imports: [CommonModule, FormsModule, ButtonsModule, InputsModule], + imports: [ + CommonModule, FormsModule, ButtonsModule, InputsModule, LayoutModule, + HasPermissionDirective, SiteSettingsTabComponent, NotificationSettingsTabComponent, + ], templateUrl: './church-profile-page.component.html', }) export class ChurchProfilePageComponent implements OnInit { @@ -17,6 +25,9 @@ export class ChurchProfilePageComponent implements OnInit { saving = false; savedMsg = ''; + /** Settings module gates the Site / Notification tabs. */ + readonly settingsPermission = { module: PermissionModules.Settings, action: 'read' as const }; + constructor(private api: DisbursementApiService) {} ngOnInit(): void { diff --git a/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.html b/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.html new file mode 100644 index 0000000..a1eb4b5 --- /dev/null +++ b/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.html @@ -0,0 +1,104 @@ +
+ + +
+
+

Email (SMTP) / 電子郵件

+ +
+ +
+ +
+ + +
+ + + + +
+ +
+ + + {{ testEmailMsg }} +
+
+ +
+ + +
+
+

Line / Line 通知

+ +
+ +
+ + + +
+ +
+ + + + {{ testLineMsg }} +
+
+ +
+ + {{ savedMsg }} +
+
diff --git a/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.ts b/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.ts new file mode 100644 index 0000000..ae422a4 --- /dev/null +++ b/APP/src/app/features/settings/components/notification-settings-tab/notification-settings-tab.component.ts @@ -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 / 失敗'; + } +} diff --git a/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.html b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.html new file mode 100644 index 0000000..6d2ca90 --- /dev/null +++ b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.html @@ -0,0 +1,35 @@ +
+
+ + + + + + +
+ +
+ + {{ savedMsg }} +
+
diff --git a/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.ts b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.ts new file mode 100644 index 0000000..2f7eac8 --- /dev/null +++ b/APP/src/app/features/settings/components/site-settings-tab/site-settings-tab.component.ts @@ -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; }, + }); + } +} diff --git a/APP/src/app/features/settings/models/settings.model.ts b/APP/src/app/features/settings/models/settings.model.ts new file mode 100644 index 0000000..dfb3350 --- /dev/null +++ b/APP/src/app/features/settings/models/settings.model.ts @@ -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 }[]; +} diff --git a/APP/src/app/features/settings/services/settings-api.service.ts b/APP/src/app/features/settings/services/settings-api.service.ts new file mode 100644 index 0000000..38521e5 --- /dev/null +++ b/APP/src/app/features/settings/services/settings-api.service.ts @@ -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 { + return this.http.get(`${this.endpoint}/site`); + } + + updateSite(request: UpdateSiteSettingRequest): Observable { + return this.http.put(`${this.endpoint}/site`, request); + } + + getNotification(): Observable { + return this.http.get(`${this.endpoint}/notification`); + } + + updateNotification(request: UpdateNotificationSettingRequest): Observable { + return this.http.put(`${this.endpoint}/notification`, request); + } + + testEmail(request: TestEmailRequest): Observable { + return this.http.post(`${this.endpoint}/notification/test-email`, request); + } + + testLine(request: TestLineRequest): Observable { + return this.http.post(`${this.endpoint}/notification/test-line`, request); + } +}