add church profile.
ci-cd-vm / ci-cd (push) Successful in 2m31s

This commit is contained in:
Chris Chen
2026-06-24 08:21:31 -07:00
parent 99585a1c0e
commit e88ea7917f
29 changed files with 1240 additions and 72 deletions
+2
View File
@@ -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";
/// <summary>All modules, in display order — drives the admin matrix UI.</summary>
public static readonly IReadOnlyList<string> All =
@@ -43,6 +44,7 @@ public static class Modules
Permissions,
SystemLogs,
AuditLogs,
Settings,
];
public static bool IsValid(string module) => All.Contains(module);
@@ -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<LineOptions> 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<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 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; }
@@ -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; }
}
+34
View File
@@ -32,6 +32,9 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
protected override void OnModelCreating(ModelBuilder 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.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<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) ─────────────────────────────────────────────
builder.Entity<Check>(entity =>
{
+47
View File
@@ -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>
/// 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<RoleManager<AppRole>>();
var userManager = services.GetRequiredService<UserManager<AppUser>>();
var env = services.GetRequiredService<IWebHostEnvironment>();
var config = services.GetRequiredService<IConfiguration>();
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);
+4
View File
@@ -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; }
@@ -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; } = "";
}
+18
View File
@@ -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)
.HasColumnType("character varying(450)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("NameZh")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("NextCheckNumber")
.HasColumnType("integer");
b.Property<string>("Phone")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("State")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -483,6 +495,10 @@ namespace ROLAC.API.Migrations
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Website")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("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<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 =>
{
b.Property<int>("Id")
@@ -1653,6 +1745,64 @@ namespace ROLAC.API.Migrations
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 =>
{
b.HasOne("ROLAC.API.Entities.AppRole", null)
+5
View File
@@ -155,14 +155,19 @@ builder.Services.AddScoped<IExpenseService, ExpenseService>();
builder.Services.AddScoped<IMonthlyStatementService, MonthlyStatementService>();
builder.Services.AddScoped<IFinanceDashboardService, FinanceDashboardService>();
builder.Services.AddScoped<IChurchProfileService, ChurchProfileService>();
builder.Services.AddScoped<ISettingsService, SettingsService>();
builder.Services.AddScoped<IDisbursementService, DisbursementService>();
builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ── 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.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,
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
@@ -15,7 +15,8 @@ public class ChurchProfileService : IChurchProfileService
var p = await GetOrCreateAsync();
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,
BankRoutingNumber = p.BankRoutingNumber, NextCheckNumber = p.NextCheckNumber,
};
@@ -24,7 +25,8 @@ public class ChurchProfileService : IChurchProfileService
public async Task UpdateAsync(UpdateChurchProfileRequest r)
{
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.BankRoutingNumber = r.BankRoutingNumber; p.NextCheckNumber = r.NextCheckNumber;
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.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<LineOptions> options)
public LineMessageChannel(HttpClient http, INotificationSettingsService settings)
{
_http = http;
_options = options.Value;
_settings = settings;
}
public Task<MessageSendResult> 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);
@@ -1,21 +1,22 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
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
{
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)
{
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);
}
@@ -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,
};
}
}
}
+115
View File
@@ -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;
}
}