Compare commits

...

18 Commits

Author SHA1 Message Date
Chris Chen 4225b49e58 Merge feature/notification-service: Email + Line notification service (API)
ci-cd-vm / ci-cd (push) Successful in 2m29s
2026-06-23 19:37:35 -07:00
Chris Chen 5a915ebdd1 Harden notifications: bump MailKit, bound webhook body, share truncation, skip soft-deleted members 2026-06-23 19:29:23 -07:00
Chris Chen fd71f5a107 Cross-link implemented notification design in NOTIFICATIONS.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:23:22 -07:00
Chris Chen 9405914d88 Register notification services and add SMTP/Line config sections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:21:47 -07:00
Chris Chen 39432ac588 Add admin NotificationsController for binding, groups, history, and send 2026-06-23 19:20:28 -07:00
Chris Chen 4c22cfaf19 Add Line webhook controller with signature verification and dispatch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:18:50 -07:00
Chris Chen c8bc7103ba Add LineNotificationService with send, binding, and group ops
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:17:10 -07:00
Chris Chen 3eeb314dc2 Add IMessageChannel and Line REST implementation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 19:13:42 -07:00
Chris Chen 0ddb34dd20 Add EmailService with recipient resolution and logging
TDD: IEmailService interface, EmailService resolves member emails + raw addresses (case-insensitive dedup), sends via ISmtpDispatcher, writes a NotificationLog per recipient (sent/failed), and never aborts the batch on a single failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:11:13 -07:00
Chris Chen 444cc70b56 Add SMTP dispatcher seam and MailKit implementation 2026-06-23 19:08:30 -07:00
Chris Chen 85bf329d93 Add Line webhook signature verification helper
Implements LineSignature.IsValid() using HMAC-SHA256 + FixedTimeEquals to prevent timing attacks; includes xUnit tests for valid, tampered, and null/empty header cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:07:01 -07:00
Chris Chen 3544b6ee78 Add change-password implementation plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:04:09 -07:00
Chris Chen 0e90f19377 Add notification entities, DbContext config, and migration
Creates MemberChannelBinding, LineBindingCode, MessagingGroup, and NotificationLog
entities under ROLAC.API.Entities.Notifications; wires DbSets and fluent config into
AppDbContext; generates EF migration AddNotifications creating the four tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 19:03:35 -07:00
Chris Chen f9c4d7edb2 Add shared notification models, records, and constants 2026-06-23 19:00:24 -07:00
Chris Chen b7372dec1f Add MailKit package and notification option classes 2026-06-23 18:58:41 -07:00
Chris Chen 21e9823008 Add self-service change-password design spec
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:58:03 -07:00
Chris Chen 583408032d Add implementation plan for Email + Line notification service
12 TDD tasks: MailKit package, entities + migration, email service (SMTP seam),
Line message channel + signature verify, Line notification service (send/binding/
groups), webhook + admin controllers, DI + config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:56:04 -07:00
Chris Chen ea0ea233a8 Add Email + Line notification service design spec
Phase 1 (API-only): IEmailService (MailKit/SMTP) + ILineNotificationService
(full approved Line module) as two peer services sharing NotificationLog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:46:44 -07:00
35 changed files with 6843 additions and 0 deletions
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class EmailServiceTests
{
// Records every email it is asked to send; can be told to throw for a given address.
private sealed class FakeSmtpDispatcher : ISmtpDispatcher
{
public List<OutboundEmail> Sent { get; } = new();
public string? FailForAddress { get; set; }
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
if (email.ToAddress == FailForAddress)
throw new InvalidOperationException("smtp rejected");
Sent.Add(email);
return Task.CompletedTask;
}
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, "member@example.com");
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var message = new EmailMessage(
MemberIds: new[] { memberId },
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
Subject: "Hi", HtmlBody: "<p>Body</p>");
var result = await service.SendAsync(message);
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
Assert.Equal(0, result.FailedCount);
Assert.Equal(2, dispatcher.Sent.Count);
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendAsync_SkipsMembersWithNoEmail()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, null);
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
Assert.Equal(0, result.SentCount);
Assert.Empty(dispatcher.Sent);
}
[Fact]
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
{
using var db = BuildDb();
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
Array.Empty<int>(),
new[] { "bad@example.com", "good@example.com" },
"Hi", "<p>Body</p>"));
Assert.Equal(1, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Single(result.Failures);
Assert.Equal("bad@example.com", result.Failures[0].Target);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
}
@@ -0,0 +1,77 @@
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Options;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineMessageChannelTests
{
// Captures the outgoing request and returns a canned response.
private sealed class CapturingHandler : HttpMessageHandler
{
public HttpRequestMessage? LastRequest { get; private set; }
public string? LastBody { get; private set; }
public HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public string ResponseBody { get; set; } = "{}";
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
}
}
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);
}
[Fact]
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.True(result.Success);
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
}
[Fact]
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
await channel.ReplyAsync("RTOKEN", "hi back");
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
}
[Fact]
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
{
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.False(result.Success);
Assert.Contains("429", result.Error);
}
}
@@ -0,0 +1,211 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Moq;
using ROLAC.API.Data;
using ROLAC.API.Data.Interceptors;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineNotificationServiceTests
{
// Records pushes; can be told to fail every call.
private sealed class FakeMessageChannel : IMessageChannel
{
public List<(string Target, string Text)> UserPushes { get; } = new();
public List<(string Target, string Text)> GroupPushes { get; } = new();
public bool Fail { get; set; }
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
{
UserPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
{
GroupPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> Task.FromResult(new MessageSendResult(true, null));
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var stored = await db.LineBindingCodes.SingleAsync();
Assert.Equal(code, stored.Code);
Assert.Null(stored.ConsumedAt);
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
}
[Fact]
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var result = await service.TryBindMemberAsync("U999", code);
Assert.True(result.Success);
Assert.Equal(memberId, result.MemberId);
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U999", binding.ExternalId);
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
}
[Fact]
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.LineBindingCodes.Add(new LineBindingCode
{
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel());
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
}
[Fact]
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U-NEW", binding.ExternalId);
}
[Fact]
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
{
using var db = BuildDb();
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.RegisterGroupAsync("G1");
await service.RegisterGroupAsync("G1"); // second call must not duplicate
Assert.Equal(1, await db.MessagingGroups.CountAsync());
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
await service.DeactivateGroupAsync("G1");
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
}
[Fact]
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
db.MessagingGroups.AddRange(activeGroup, deadGroup);
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId },
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
Assert.Equal(2, result.SentCount); // member + active group only
Assert.Single(channel.UserPushes);
Assert.Single(channel.GroupPushes); // inactive group skipped
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
[Fact]
public async Task SendLineAsync_SkipsSoftDeletedMembers()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-DEL", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
// Soft-delete the member.
var member = await db.Members.FirstAsync(m => m.Id == memberId);
member.IsDeleted = true;
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Empty(channel.UserPushes);
}
}
@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
using ROLAC.API.Services.Notifications;
using Xunit;
namespace ROLAC.API.Tests.Services.Notifications;
public class LineSignatureTests
{
private const string Secret = "test-channel-secret";
private static string Sign(string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(Secret));
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(body)));
}
[Fact]
public void IsValid_ReturnsTrue_ForMatchingSignature()
{
var body = """{"events":[]}""";
var signature = Sign(body);
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes(body), signature);
Assert.True(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForTamperedBody()
{
var signature = Sign("""{"events":[]}""");
var result = LineSignature.IsValid(Secret, Encoding.UTF8.GetBytes("""{"events":[1]}"""), signature);
Assert.False(result);
}
[Fact]
public void IsValid_ReturnsFalse_ForNullOrEmptyHeader()
{
var body = Encoding.UTF8.GetBytes("""{"events":[]}""");
Assert.False(LineSignature.IsValid(Secret, body, null));
Assert.False(LineSignature.IsValid(Secret, body, ""));
}
}
@@ -0,0 +1,89 @@
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;
namespace ROLAC.API.Controllers;
/// <summary>
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
/// returns 400 only on signature failure.
/// </summary>
[ApiController]
[Route("api/line")]
[AllowAnonymous]
public sealed class LineWebhookController : ControllerBase
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel;
private readonly LineOptions _options;
public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
{
_line = line;
_channel = channel;
_options = options.Value;
}
[HttpPost("webhook")]
[RequestSizeLimit(262_144)]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
if (payload?.Events is not null)
foreach (var evt in payload.Events)
await DispatchAsync(evt, ct);
return Ok();
}
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
{
switch (evt.Type)
{
case "follow":
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
break;
case "message":
if (evt.Message?.Type == "text"
&& evt.Source?.UserId is { } userId
&& evt.Message.Text is { } text)
{
var result = await _line.TryBindMemberAsync(userId, text, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
}
break;
case "join":
if (evt.Source?.GroupId is { } joinGroupId)
{
await _line.RegisterGroupAsync(joinGroupId, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
}
break;
case "leave":
if (evt.Source?.GroupId is { } leaveGroupId)
await _line.DeactivateGroupAsync(leaveGroupId, ct);
break;
}
}
}
@@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Notifications;
using ROLAC.API.Services.Logging;
using ROLAC.API.Services.Notifications;
namespace ROLAC.API.Controllers;
/// <summary>
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
/// management, send history, and manual send — the manual send endpoints are the only way to fire
/// a message before a UI exists; programmatic callers use the services directly.
/// </summary>
[ApiController]
[Route("api/notifications")]
[Authorize]
public sealed class NotificationsController : ControllerBase
{
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly AppDbContext _db;
private readonly CurrentUserAccessor _currentUser;
public NotificationsController(
IEmailService email, ILineNotificationService line,
AppDbContext db, CurrentUserAccessor currentUser)
{
_email = email;
_line = line;
_db = db;
_currentUser = currentUser;
}
[HttpPost("members/{id:int}/line-binding-code")]
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
[HttpGet("groups")]
public async Task<IActionResult> Groups(CancellationToken ct)
=> Ok(await _db.MessagingGroups
.OrderBy(g => g.Id)
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
.ToListAsync(ct));
[HttpPut("groups/{id:int}")]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
{
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
if (group is null) return NotFound();
group.Name = request.Name;
group.IsActive = request.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpGet("history")]
public async Task<IActionResult> History(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var size = Math.Clamp(pageSize, 1, 200);
var skip = (Math.Max(page, 1) - 1) * size;
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
var total = await query.CountAsync(ct);
var items = await query
.Skip(skip).Take(size)
.Select(l => new
{
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
l.Status, l.Error, l.SentByUserId, l.SentAt,
})
.ToListAsync(ct);
return Ok(new { total, items });
}
[HttpPost("send-line")]
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
=> Ok(await _line.SendLineAsync(
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
_currentUser.UserIdOrSystem, ct));
[HttpPost("send-email")]
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
=> Ok(await _email.SendAsync(new EmailMessage(
MemberIds: request.MemberIds ?? [],
Addresses: request.Addresses ?? [],
Subject: request.Subject,
HtmlBody: request.HtmlBody,
Attachments: null,
SentByUserId: _currentUser.UserIdOrSystem), ct));
}
@@ -0,0 +1,28 @@
namespace ROLAC.API.DTOs.Notifications;
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
public sealed class LineWebhookPayload
{
public List<LineWebhookEvent>? Events { get; set; }
}
public sealed class LineWebhookEvent
{
public string? Type { get; set; } // follow | message | join | leave | ...
public string? ReplyToken { get; set; }
public LineWebhookSource? Source { get; set; }
public LineWebhookMessage? Message { get; set; }
}
public sealed class LineWebhookSource
{
public string? Type { get; set; } // user | group | room
public string? UserId { get; set; }
public string? GroupId { get; set; }
}
public sealed class LineWebhookMessage
{
public string? Type { get; set; } // text | image | ...
public string? Text { get; set; }
}
@@ -0,0 +1,7 @@
namespace ROLAC.API.DTOs.Notifications;
public sealed record UpdateGroupRequest(string? Name, bool IsActive);
public sealed record SendLineRequest(string Body, int[]? MemberIds, int[]? GroupIds);
public sealed record SendEmailRequest(string Subject, string HtmlBody, int[]? MemberIds, string[]? Addresses);
+49
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data.Logging;
using ROLAC.API.Entities;
using ROLAC.API.Entities.Notifications;
namespace ROLAC.API.Data;
@@ -26,6 +27,11 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
public DbSet<MealAttendance> MealAttendances => Set<MealAttendance>();
public DbSet<RolePermission> RolePermissions => Set<RolePermission>();
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
@@ -326,6 +332,49 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
entity.HasIndex(e => new { e.Year, e.Month }).IsUnique();
});
// ── Notifications (email + Line) ─────────────────────────────────────
builder.Entity<MemberChannelBinding>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<LineBindingCode>(entity =>
{
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
entity.HasIndex(e => e.Code);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessagingGroup>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Name).HasMaxLength(200);
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
});
builder.Entity<NotificationLog>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
entity.Property(e => e.Subject).HasMaxLength(300);
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.SentAt);
entity.HasIndex(e => e.Channel);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.MessagingGroup).WithMany()
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
});
// ── SystemLog / AuditLog (append-only) ───────────────────────────────
// Mapped here for SCHEMA only — there are deliberately no DbSets on this
// context, so business code can't write logs through the audited context.
@@ -0,0 +1,14 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>A short-lived code a member types to the Line bot to complete account binding.</summary>
public class LineBindingCode
{
public int Id { get; set; }
public string Code { get; set; } = null!;
public int MemberId { get; set; }
public Member? Member { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? ConsumedAt { get; set; } // null = unused
}
@@ -0,0 +1,17 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>
/// Binds a member to an external channel account (e.g. a Line userId). Separate table so future
/// channels don't require changes to Member.
/// </summary>
public class MemberChannelBinding
{
public int Id { get; set; }
public int MemberId { get; set; }
public Member? Member { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line userId
public DateTime BoundAt { get; set; }
}
@@ -0,0 +1,12 @@
namespace ROLAC.API.Entities.Notifications;
/// <summary>A Line group the bot was added to. Named by an admin after the join event.</summary>
public class MessagingGroup
{
public int Id { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line groupId
public string? Name { get; set; }
public bool IsActive { get; set; } = true;
public DateTime RegisteredAt { get; set; }
}
@@ -0,0 +1,22 @@
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>An append-only audit row for every email or Line send (success or failure).</summary>
public class NotificationLog
{
public long Id { get; set; }
public string Channel { get; set; } = null!; // "email" | "line"
public string TargetType { get; set; } = null!; // "email" | "user" | "group"
public string TargetExternalId { get; set; } = null!; // email address OR Line id
public string? Subject { get; set; } // email only
public int? MemberId { get; set; }
public Member? Member { get; set; }
public int? MessagingGroupId { get; set; }
public MessagingGroup? MessagingGroup { get; set; }
public string Body { get; set; } = null!;
public string Status { get; set; } = null!; // "sent" | "failed"
public string? Error { get; set; }
public string SentByUserId { get; set; } = null!;
public DateTime SentAt { get; set; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,176 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace ROLAC.API.Migrations
{
/// <inheritdoc />
public partial class AddNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LineBindingCodes",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Code = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
MemberId = table.Column<int>(type: "integer", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ConsumedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LineBindingCodes", x => x.Id);
table.ForeignKey(
name: "FK_LineBindingCodes_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MemberChannelBindings",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
MemberId = table.Column<int>(type: "integer", nullable: false),
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
BoundAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MemberChannelBindings", x => x.Id);
table.ForeignKey(
name: "FK_MemberChannelBindings_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "MessagingGroups",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ExternalId = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
IsActive = table.Column<bool>(type: "boolean", nullable: false),
RegisteredAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MessagingGroups", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NotificationLogs",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TargetType = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
TargetExternalId = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Subject = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
MemberId = table.Column<int>(type: "integer", nullable: true),
MessagingGroupId = table.Column<int>(type: "integer", nullable: true),
Body = table.Column<string>(type: "text", nullable: false),
Status = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
Error = table.Column<string>(type: "text", nullable: true),
SentByUserId = table.Column<string>(type: "character varying(450)", maxLength: 450, nullable: false),
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NotificationLogs", x => x.Id);
table.ForeignKey(
name: "FK_NotificationLogs_Members_MemberId",
column: x => x.MemberId,
principalTable: "Members",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_NotificationLogs_MessagingGroups_MessagingGroupId",
column: x => x.MessagingGroupId,
principalTable: "MessagingGroups",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_LineBindingCodes_Code",
table: "LineBindingCodes",
column: "Code");
migrationBuilder.CreateIndex(
name: "IX_LineBindingCodes_MemberId",
table: "LineBindingCodes",
column: "MemberId");
migrationBuilder.CreateIndex(
name: "IX_MemberChannelBindings_Channel_ExternalId",
table: "MemberChannelBindings",
columns: new[] { "Channel", "ExternalId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MemberChannelBindings_MemberId_Channel",
table: "MemberChannelBindings",
columns: new[] { "MemberId", "Channel" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_MessagingGroups_Channel_ExternalId",
table: "MessagingGroups",
columns: new[] { "Channel", "ExternalId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_Channel",
table: "NotificationLogs",
column: "Channel");
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_MemberId",
table: "NotificationLogs",
column: "MemberId");
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_MessagingGroupId",
table: "NotificationLogs",
column: "MessagingGroupId");
migrationBuilder.CreateIndex(
name: "IX_NotificationLogs_SentAt",
table: "NotificationLogs",
column: "SentAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "LineBindingCodes");
migrationBuilder.DropTable(
name: "MemberChannelBindings");
migrationBuilder.DropTable(
name: "NotificationLogs");
migrationBuilder.DropTable(
name: "MessagingGroups");
}
}
}
@@ -1323,6 +1323,174 @@ namespace ROLAC.API.Migrations
b.ToTable("MonthlyStatements");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("MemberId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("MemberId");
b.ToTable("LineBindingCodes");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("BoundAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("MemberId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Channel", "ExternalId")
.IsUnique();
b.HasIndex("MemberId", "Channel")
.IsUnique();
b.ToTable("MemberChannelBindings");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MessagingGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("RegisteredAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("Channel", "ExternalId")
.IsUnique();
b.ToTable("MessagingGroups");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Channel")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Error")
.HasColumnType("text");
b.Property<int?>("MemberId")
.HasColumnType("integer");
b.Property<int?>("MessagingGroupId")
.HasColumnType("integer");
b.Property<DateTime>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("SentByUserId")
.IsRequired()
.HasMaxLength(450)
.HasColumnType("character varying(450)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Subject")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("TargetExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("TargetType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("Channel");
b.HasIndex("MemberId");
b.HasIndex("MessagingGroupId");
b.HasIndex("SentAt");
b.ToTable("NotificationLogs");
});
modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b =>
{
b.Property<int>("Id")
@@ -1645,6 +1813,45 @@ namespace ROLAC.API.Migrations
b.Navigation("FamilyUnit");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Member");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.MemberChannelBinding", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Member");
});
modelBuilder.Entity("ROLAC.API.Entities.Notifications.NotificationLog", b =>
{
b.HasOne("ROLAC.API.Entities.Member", "Member")
.WithMany()
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ROLAC.API.Entities.Notifications.MessagingGroup", "MessagingGroup")
.WithMany()
.HasForeignKey("MessagingGroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Member");
b.Navigation("MessagingGroup");
});
modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b =>
{
b.HasOne("ROLAC.API.Entities.AppUser", "User")
+12
View File
@@ -160,6 +160,18 @@ builder.Services.AddScoped<ROLAC.API.Services.Disbursement.ICheckPrintService,
ROLAC.API.Services.Disbursement.CheckPrintService>();
builder.Services.AddScoped<IMealAttendanceService, MealAttendanceService>();
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
ROLAC.API.Services.Notifications.EmailService>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationService,
ROLAC.API.Services.Notifications.LineNotificationService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
ROLAC.API.Services.Notifications.LineMessageChannel>();
// ---------------------------------------------------------------------------
// Configurable role-based permissions (RBAC matrix)
// ---------------------------------------------------------------------------
+1
View File
@@ -12,6 +12,7 @@
Provides DevExpress.Drawing.v24.1.Skia.dll; without it RichEditDocumentServer
throws DllNotFoundException at runtime on Linux (Windows falls back to GDI+). -->
<PackageReference Include="DevExpress.Drawing.Skia" Version="24.1.3" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Entities.Notifications;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// Resolves recipients (member emails + raw addresses, deduped), sends each via the SMTP
/// dispatcher, and writes a NotificationLog row per recipient. A single failure never aborts the
/// batch — it is recorded and reported in the summary.
/// </summary>
public sealed class EmailService : IEmailService
{
private readonly AppDbContext _db;
private readonly ISmtpDispatcher _dispatcher;
private readonly CurrentUserAccessor _currentUser;
public EmailService(AppDbContext db, ISmtpDispatcher dispatcher, CurrentUserAccessor currentUser)
{
_db = db;
_dispatcher = dispatcher;
_currentUser = currentUser;
}
public async Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default)
{
var recipients = await ResolveRecipientsAsync(message, ct);
if (recipients.Count == 0) return NotificationResult.Empty;
var sentBy = message.SentByUserId ?? _currentUser.UserIdOrSystem;
var attachments = message.Attachments ?? Array.Empty<EmailAttachment>();
var failures = new List<NotificationFailure>();
var sentCount = 0;
foreach (var recipient in recipients)
{
var log = new NotificationLog
{
Channel = NotificationChannels.Email,
TargetType = NotificationTargetTypes.Email,
TargetExternalId = recipient.Address,
Subject = message.Subject,
MemberId = recipient.MemberId,
Body = NotificationLogText.Truncate(message.HtmlBody),
SentByUserId = sentBy,
SentAt = DateTime.UtcNow,
};
try
{
await _dispatcher.SendAsync(
new OutboundEmail(recipient.Address, message.Subject, message.HtmlBody, attachments), ct);
log.Status = NotificationStatuses.Sent;
sentCount++;
}
catch (Exception ex)
{
log.Status = NotificationStatuses.Failed;
log.Error = ex.Message;
failures.Add(new NotificationFailure(recipient.Address, ex.Message));
}
_db.NotificationLogs.Add(log);
}
await _db.SaveChangesAsync(ct);
return new NotificationResult(sentCount, failures.Count, failures);
}
private async Task<IReadOnlyList<(string Address, int? MemberId)>> ResolveRecipientsAsync(
EmailMessage message, CancellationToken ct)
{
var resolved = new List<(string Address, int? MemberId)>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (message.MemberIds.Count > 0)
{
var members = await _db.Members
.Where(member => message.MemberIds.Contains(member.Id) && member.Email != null && member.Email != "")
.Select(member => new { member.Id, member.Email })
.ToListAsync(ct);
foreach (var member in members)
if (seen.Add(member.Email!))
resolved.Add((member.Email!, member.Id));
}
foreach (var address in message.Addresses)
{
var trimmed = address?.Trim();
if (!string.IsNullOrWhiteSpace(trimmed) && seen.Add(trimmed))
resolved.Add((trimmed, null));
}
return resolved;
}
}
@@ -0,0 +1,6 @@
namespace ROLAC.API.Services.Notifications;
public interface IEmailService
{
Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default);
}
@@ -0,0 +1,20 @@
namespace ROLAC.API.Services.Notifications;
/// <summary>Outcome of a webhook-driven binding attempt.</summary>
public sealed record LineBindingResult(bool Success, string Message, int? MemberId);
/// <summary>
/// Line-specific notification operations: outbound push to bound members/groups, plus the
/// webhook-driven binding-code generation/consumption and group registration.
/// </summary>
public interface ILineNotificationService
{
Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
string sentByUserId, CancellationToken ct = default);
Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default);
Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default);
Task RegisterGroupAsync(string externalId, CancellationToken ct = default);
Task DeactivateGroupAsync(string externalId, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
namespace ROLAC.API.Services.Notifications;
/// <summary>Result of one Line REST call.</summary>
public sealed record MessageSendResult(bool Success, string? Error);
/// <summary>Abstraction over a chat channel's send/reply (Line today; future channels later).</summary>
public interface IMessageChannel
{
Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default);
Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default);
Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default);
}
@@ -0,0 +1,14 @@
namespace ROLAC.API.Services.Notifications;
/// <summary>One outbound email envelope handed to the SMTP transport.</summary>
public sealed record OutboundEmail(
string ToAddress,
string Subject,
string HtmlBody,
IReadOnlyList<EmailAttachment> Attachments);
/// <summary>Thin seam over the actual MailKit send so EmailService stays unit-testable.</summary>
public interface ISmtpDispatcher
{
Task SendAsync(OutboundEmail email, CancellationToken ct = default);
}
@@ -0,0 +1,52 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Options;
namespace ROLAC.API.Services.Notifications;
/// <summary>Sends text messages and replies via the Line Messaging API REST endpoints.</summary>
public sealed class LineMessageChannel : IMessageChannel
{
private const string PushUrl = "https://api.line.me/v2/bot/message/push";
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
private readonly HttpClient _http;
private readonly LineOptions _options;
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
{
_http = http;
_options = options.Value;
}
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> PostAsync(ReplyUrl, new { replyToken, messages = new[] { new { type = "text", text } } }, ct);
private async Task<MessageSendResult> PostAsync(string url, object payload, CancellationToken ct)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
using var response = await _http.SendAsync(request, ct);
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
var body = await response.Content.ReadAsStringAsync(ct);
return new MessageSendResult(false, $"{(int)response.StatusCode}: {body}");
}
catch (Exception ex)
{
return new MessageSendResult(false, ex.Message);
}
}
}
@@ -0,0 +1,168 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Entities.Notifications;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// Line outbound push + webhook-driven binding/group operations. All sends write a
/// NotificationLog row; binding consumes a short-lived, single-use code.
/// </summary>
public sealed class LineNotificationService : ILineNotificationService
{
private const string Channel = NotificationChannels.Line;
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1
private const int CodeLength = 6;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15);
private readonly AppDbContext _db;
private readonly IMessageChannel _channel;
public LineNotificationService(AppDbContext db, IMessageChannel channel)
{
_db = db;
_channel = channel;
}
public async Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
string sentByUserId, CancellationToken ct = default)
{
var failures = new List<NotificationFailure>();
var sentCount = 0;
var liveMemberIds = await _db.Members
.Where(m => memberIds.Contains(m.Id))
.Select(m => m.Id)
.ToListAsync(ct);
var bindings = await _db.MemberChannelBindings
.Where(b => b.Channel == Channel && liveMemberIds.Contains(b.MemberId))
.ToListAsync(ct);
foreach (var binding in bindings)
{
var result = await _channel.PushToUserAsync(binding.ExternalId, body, ct);
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.User, binding.ExternalId,
body, sentByUserId, result, memberId: binding.MemberId));
if (result.Success) sentCount++;
else failures.Add(new NotificationFailure($"member:{binding.MemberId}", result.Error ?? "unknown"));
}
var groups = await _db.MessagingGroups
.Where(g => g.Channel == Channel && g.IsActive && groupIds.Contains(g.Id))
.ToListAsync(ct);
foreach (var group in groups)
{
var result = await _channel.PushToGroupAsync(group.ExternalId, body, ct);
_db.NotificationLogs.Add(BuildLog(NotificationTargetTypes.Group, group.ExternalId,
body, sentByUserId, result, groupId: group.Id));
if (result.Success) sentCount++;
else failures.Add(new NotificationFailure($"group:{group.Id}", result.Error ?? "unknown"));
}
await _db.SaveChangesAsync(ct);
return new NotificationResult(sentCount, failures.Count, failures);
}
public async Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default)
{
var code = GenerateCode();
_db.LineBindingCodes.Add(new LineBindingCode
{
Code = code,
MemberId = memberId,
ExpiresAt = DateTime.UtcNow.Add(CodeLifetime),
});
await _db.SaveChangesAsync(ct);
return code;
}
public async Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default)
{
var normalized = code.Trim().ToUpperInvariant();
var now = DateTime.UtcNow;
var bindingCode = await _db.LineBindingCodes
.FirstOrDefaultAsync(c => c.Code == normalized && c.ConsumedAt == null && c.ExpiresAt > now, ct);
if (bindingCode is null)
return new LineBindingResult(false, "綁定碼無效或已過期。", null);
bindingCode.ConsumedAt = now;
var existing = await _db.MemberChannelBindings
.FirstOrDefaultAsync(b => b.Channel == Channel && b.MemberId == bindingCode.MemberId, ct);
if (existing is null)
{
_db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = bindingCode.MemberId,
Channel = Channel,
ExternalId = externalId,
BoundAt = now,
});
}
else
{
existing.ExternalId = externalId;
existing.BoundAt = now;
}
await _db.SaveChangesAsync(ct);
return new LineBindingResult(true, "綁定成功!", bindingCode.MemberId);
}
public async Task RegisterGroupAsync(string externalId, CancellationToken ct = default)
{
var group = await _db.MessagingGroups
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
if (group is null)
{
_db.MessagingGroups.Add(new MessagingGroup
{
Channel = Channel,
ExternalId = externalId,
IsActive = true,
RegisteredAt = DateTime.UtcNow,
});
}
else
{
group.IsActive = true;
}
await _db.SaveChangesAsync(ct);
}
public async Task DeactivateGroupAsync(string externalId, CancellationToken ct = default)
{
var group = await _db.MessagingGroups
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
if (group is not null)
{
group.IsActive = false;
await _db.SaveChangesAsync(ct);
}
}
private static NotificationLog BuildLog(string targetType, string externalId, string body,
string sentBy, MessageSendResult result, int? memberId = null, int? groupId = null) => new()
{
Channel = Channel,
TargetType = targetType,
TargetExternalId = externalId,
MemberId = memberId,
MessagingGroupId = groupId,
Body = NotificationLogText.Truncate(body),
Status = result.Success ? NotificationStatuses.Sent : NotificationStatuses.Failed,
Error = result.Error,
SentByUserId = sentBy,
SentAt = DateTime.UtcNow,
};
private static string GenerateCode()
{
var bytes = RandomNumberGenerator.GetBytes(CodeLength);
var chars = new char[CodeLength];
for (var i = 0; i < CodeLength; i++)
chars[i] = CodeAlphabet[bytes[i] % CodeAlphabet.Length];
return new string(chars);
}
}
@@ -0,0 +1,20 @@
using System.Security.Cryptography;
using System.Text;
namespace ROLAC.API.Services.Notifications;
/// <summary>Verifies the X-Line-Signature header (HMAC-SHA256 of the raw body, base64).</summary>
public static class LineSignature
{
public static bool IsValid(string channelSecret, byte[] rawBody, string? signatureHeader)
{
if (string.IsNullOrEmpty(signatureHeader)) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(channelSecret));
var expected = Convert.ToBase64String(hmac.ComputeHash(rawBody));
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signatureHeader));
}
}
@@ -0,0 +1,38 @@
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>
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
{
private readonly SmtpOptions _options;
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
message.To.Add(MailboxAddress.Parse(email.ToAddress));
message.Subject = email.Subject;
var builder = new BodyBuilder { HtmlBody = email.HtmlBody };
foreach (var attachment in email.Attachments)
{
builder.Attachments.Add(
attachment.FileName, attachment.Content, ContentType.Parse(attachment.ContentType));
}
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);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
}
@@ -0,0 +1,59 @@
namespace ROLAC.API.Services.Notifications;
/// <summary>Canonical channel discriminators stored in NotificationLog.Channel.</summary>
public static class NotificationChannels
{
public const string Email = "email";
public const string Line = "line";
}
/// <summary>Canonical target-type discriminators stored in NotificationLog.TargetType.</summary>
public static class NotificationTargetTypes
{
public const string Email = "email";
public const string User = "user";
public const string Group = "group";
}
/// <summary>Canonical send statuses stored in NotificationLog.Status.</summary>
public static class NotificationStatuses
{
public const string Sent = "sent";
public const string Failed = "failed";
}
/// <summary>One failed delivery within a send batch.</summary>
public sealed record NotificationFailure(string Target, string Error);
/// <summary>Aggregated outcome of a send call.</summary>
public sealed record NotificationResult(
int SentCount, int FailedCount, IReadOnlyList<NotificationFailure> Failures)
{
public static NotificationResult Empty { get; } =
new(0, 0, Array.Empty<NotificationFailure>());
}
/// <summary>A file attached to an outbound email.</summary>
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
/// <summary>
/// A request to send one email to a set of members (resolved via Member.Email) and/or raw
/// addresses. The caller supplies the final HTML body — no templating in this phase.
/// </summary>
public sealed record EmailMessage(
IReadOnlyList<int> MemberIds,
IReadOnlyList<string> Addresses,
string Subject,
string HtmlBody,
IReadOnlyList<EmailAttachment>? Attachments = null,
string? SentByUserId = null);
/// <summary>Helpers for building NotificationLog rows consistently across channels.</summary>
public static class NotificationLogText
{
public const int BodyMaxLength = 8000;
/// <summary>Caps a body string so an oversized message can't bloat the log table.</summary>
public static string Truncate(string body) =>
body.Length <= BodyMaxLength ? body : body[..BodyMaxLength] + "…[truncated]";
}
@@ -0,0 +1,20 @@
namespace ROLAC.API.Services.Notifications;
/// <summary>SMTP transport settings (bound from the "Smtp" config section).</summary>
public sealed class SmtpOptions
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true; // true → STARTTLS
public string User { get; set; } = "";
public string Password { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
}
/// <summary>Line Messaging API settings (bound from the "Line" config section).</summary>
public sealed class LineOptions
{
public string ChannelAccessToken { get; set; } = "";
public string ChannelSecret { get; set; } = "";
}
+13
View File
@@ -28,5 +28,18 @@
},
"Storage": {
"LocalRoot": "App_Data/storage"
},
"Smtp": {
"Host": "",
"Port": 587,
"UseSsl": true,
"User": "",
"Password": "",
"FromAddress": "noreply@rolac.org",
"FromName": "River of Life Christian Church"
},
"Line": {
"ChannelAccessToken": "",
"ChannelSecret": ""
}
}
+4
View File
@@ -17,6 +17,10 @@
## 各渠道設計
> **實作狀態 (2026-06-23)** Email (SMTP/MailKit) + Line 已於 API 端實作,見
> [docs/superpowers/specs/2026-06-23-notification-service-email-line-design.md](superpowers/specs/2026-06-23-notification-service-email-line-design.md)。
> 本檔早期願景中的 SendGrid/Push/SMS 仍為未來規劃。
### Email (SendGrid)
**觸發時機**
@@ -0,0 +1,983 @@
# Change Password (Self-Service) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let an authenticated user change their own password from a new Account Settings page, verifying the current password, enforcing the existing Identity policy, and revoking the user's other sessions on success.
**Architecture:** New `POST /api/auth/change-password` endpoint → `AuthService.ChangePasswordAsync` uses `UserManager.ChangePasswordAsync` (verifies current password + applies policy + bumps SecurityStamp), then revokes the user's other refresh tokens (keeps the current cookie's token) and writes a security audit entry. Frontend adds a `/user-portal/account` page hosting a focused `ChangePasswordFormComponent`, an `authService.changePassword()` call, and wires the previously-disabled user-menu "Settings" item to the page.
**Tech Stack:** C# / ASP.NET Core Identity / EF Core (in-memory for tests) / xUnit + Moq (backend); Angular standalone components / Reactive Forms / Kendo UI v20 / Karma + Jasmine (frontend). No DB migration — uses inherited `IdentityUser.PasswordHash`/`SecurityStamp` and the existing `RefreshToken` table.
**Reference spec:** `docs/superpowers/specs/2026-06-23-change-password-design.md`
**No schema change / no migration is required.**
---
## File Structure
**Backend (create):**
- `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs` — request DTO.
**Backend (modify):**
- `API/ROLAC.API/Entities/Logging/AuditLog.cs` — add `PasswordChanged` audit action constant.
- `API/ROLAC.API/Services/IAuthService.cs` — add `ChangePasswordAsync` to the interface.
- `API/ROLAC.API/Services/AuthService.cs` — implement `ChangePasswordAsync`.
- `API/ROLAC.API/Controllers/AuthController.cs` — add `POST change-password` action.
**Backend (test):**
- `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` — add change-password tests.
**Frontend (create):**
- `APP/src/app/features/account/validators/password.validators.ts` — strength + match validators.
- `APP/src/app/features/account/validators/password.validators.spec.ts` — validator tests.
- `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts`
- `APP/src/app/features/account/components/change-password-form/change-password-form.component.html`
- `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts`
- `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts`
- `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html`
**Frontend (modify):**
- `APP/src/app/shared/services/auth.service.ts` — add `changePassword()`.
- `APP/src/app/shared/services/auth.service.spec.ts` — add `changePassword()` test.
- `APP/src/app/app.routes.ts` — register `account` route.
- `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts` — wire "Settings" menu item.
---
## Commands reference
- **Backend tests (all):** `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
- **Backend tests (filtered):** `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChangePassword"`
- **Frontend tests (single run):** run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
- Always build/test with `-c Release` (Visual Studio holds a lock on `bin/Debug`).
---
## Task 1: Add `PasswordChanged` audit action + `ChangePasswordRequest` DTO
**Files:**
- Modify: `API/ROLAC.API/Entities/Logging/AuditLog.cs:39-61`
- Create: `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs`
- [ ] **Step 1: Add the `PasswordChanged` constant**
In `API/ROLAC.API/Entities/Logging/AuditLog.cs`, inside `public static class AuditActions`, add the constant after `RoleChanged` (line 47) and include it in the `All` list.
Add the field (after the `RoleChanged` line):
```csharp
public const string PasswordChanged = "PasswordChanged";
```
Then update the `All` collection to include it — change the existing block to:
```csharp
public static readonly IReadOnlyList<string> All =
[
Create, Update, Delete, Login, Logout, LoginFailed, RoleChanged,
PasswordChanged, UserDeactivated, PermissionChanged, CheckIssued,
CheckVoided, ExpenseApproved, StatementFinalized,
];
```
- [ ] **Step 2: Create the request DTO**
Create `API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs`:
```csharp
using System.ComponentModel.DataAnnotations;
namespace ROLAC.API.DTOs.Auth;
public class ChangePasswordRequest
{
[Required]
[MaxLength(128)]
public string CurrentPassword { get; set; } = null!;
[Required]
[MinLength(8)]
[MaxLength(128)]
public string NewPassword { get; set; } = null!;
}
```
- [ ] **Step 3: Build to verify it compiles**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release`
Expected: Build succeeded (0 errors).
- [ ] **Step 4: Commit**
```bash
git add API/ROLAC.API/Entities/Logging/AuditLog.cs API/ROLAC.API/DTOs/Auth/ChangePasswordRequest.cs
git commit -m "feat(auth): add PasswordChanged audit action and ChangePasswordRequest DTO"
```
---
## Task 2: Add `ChangePasswordAsync` to the auth service (TDD)
**Files:**
- Modify: `API/ROLAC.API/Services/IAuthService.cs`
- Modify: `API/ROLAC.API/Services/AuthService.cs`
- Test: `API/ROLAC.API.Tests/Services/AuthServiceTests.cs`
The existing test helper `BuildUserManager` (lines 34-58) does **not** set up `ChangePasswordAsync`. We add a setup so the mock returns a configurable result.
- [ ] **Step 1: Extend the `BuildUserManager` helper to support `ChangePasswordAsync`**
In `API/ROLAC.API.Tests/Services/AuthServiceTests.cs`, change the `BuildUserManager` signature and add one setup. Replace the method signature line and add the setup before `return mgr;`.
Change the signature (line 34-37) to add a `changePasswordResult` parameter:
```csharp
private static Mock<UserManager<AppUser>> BuildUserManager(
AppUser? findResult = null,
bool passwordOk = true,
IList<string>? roles = null,
IdentityResult? changePasswordResult = null)
{
```
Add this setup just before `return mgr;` (after the `UpdateAsync` setup at line 54-55):
```csharp
mgr.Setup(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(changePasswordResult ?? IdentityResult.Success);
```
- [ ] **Step 2: Write the failing tests**
Append these tests inside the `AuthServiceTests` class in `API/ROLAC.API.Tests/Services/AuthServiceTests.cs` (before the closing brace), adding a section header:
```csharp
// -----------------------------------------------------------------------
// Change password tests
// -----------------------------------------------------------------------
[Fact]
public async Task ChangePassword_ValidRequest_Succeeds()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", null);
Assert.True(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(user, "Old1234!", "New1234!"), Times.Once);
}
[Fact]
public async Task ChangePassword_UnknownUser_Fails()
{
var um = BuildUserManager(findResult: null);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("missing", "Old1234!", "New1234!", null);
Assert.False(result.Succeeded);
um.Verify(m => m.ChangePasswordAsync(
It.IsAny<AppUser>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task ChangePassword_WrongCurrentPassword_ReturnsFailure()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var failed = IdentityResult.Failed(new IdentityError { Description = "Incorrect password." });
var um = BuildUserManager(findResult: user, changePasswordResult: failed);
var ts = BuildTokenService();
var sut = BuildSut(um, ts, BuildDb());
var result = await sut.ChangePasswordAsync("u1", "WrongOld!", "New1234!", null);
Assert.False(result.Succeeded);
}
[Fact]
public async Task ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent()
{
var user = new AppUser { Id = "u1", Email = "a@b.com", UserName = "a@b.com", IsActive = true };
var um = BuildUserManager(findResult: user);
var ts = BuildTokenService(); // HashToken(x) => "hash:{x}"
var db = BuildDb();
// Current session token (raw "current-raw" => "hash:current-raw")
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:current-raw",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-1),
});
// Another active session on a different device
db.RefreshTokens.Add(new RefreshToken
{
UserId = "u1",
TokenHash = "hash:other-device",
ExpiresAt = DateTime.UtcNow.AddDays(30),
CreatedAt = DateTime.UtcNow.AddHours(-2),
});
await db.SaveChangesAsync();
var sut = BuildSut(um, ts, db);
await sut.ChangePasswordAsync("u1", "Old1234!", "New1234!", "current-raw");
var current = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:current-raw");
var other = db.RefreshTokens.Single(rt => rt.TokenHash == "hash:other-device");
Assert.Null(current.RevokedAt); // current session preserved
Assert.NotNull(other.RevokedAt); // other session revoked
}
```
- [ ] **Step 3: Add the interface method**
In `API/ROLAC.API/Services/IAuthService.cs`, add this method to the `IAuthService` interface (after `LogoutAsync`, before `BuildUserInfoAsync`). Add `using Microsoft.AspNetCore.Identity;` at the top of the file.
```csharp
/// <summary>
/// Changes the password for an already-authenticated user. Verifies the current
/// password and enforces the configured Identity password policy via
/// <c>UserManager.ChangePasswordAsync</c>. On success, revokes the user's other
/// active refresh tokens (keeping the one matching <paramref name="currentRawRefreshToken"/>)
/// and writes a security audit entry. Returns the <see cref="IdentityResult"/> so the
/// caller can surface failures; never throws on a bad password.
/// </summary>
Task<IdentityResult> ChangePasswordAsync(
string userId,
string currentPassword,
string newPassword,
string? currentRawRefreshToken);
```
- [ ] **Step 4: Implement the service method**
In `API/ROLAC.API/Services/AuthService.cs`, add this method after `LogoutAsync` (after line 160), before the "Private helpers" region. `IdentityResult` is available via the existing `using Microsoft.AspNetCore.Identity;` (line 1).
```csharp
// -------------------------------------------------------------------------
// Change password
// -------------------------------------------------------------------------
public async Task<IdentityResult> ChangePasswordAsync(
string userId, string currentPassword, string newPassword, string? currentRawRefreshToken)
{
var user = await _userManager.FindByIdAsync(userId);
if (user is null)
return IdentityResult.Failed(new IdentityError
{
Code = "UserNotFound",
Description = "User not found.",
});
var result = await _userManager.ChangePasswordAsync(user, currentPassword, newPassword);
if (!result.Succeeded)
return result;
// Revoke the user's other active sessions; keep the current one alive.
var currentHash = currentRawRefreshToken is null
? null
: _tokenService.HashToken(currentRawRefreshToken);
var otherTokens = await _db.RefreshTokens
.Where(rt => rt.UserId == userId
&& rt.RevokedAt == null
&& rt.TokenHash != currentHash)
.ToListAsync();
foreach (var token in otherTokens)
token.RevokedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
_audit.Write(
AuditActions.PasswordChanged, AuditCategories.Security, LogLevelEnum.Information,
entityName: nameof(AppUser), entityId: user.Id,
summary: $"Password changed: {user.Email}",
userId: user.Id, userEmail: user.Email);
return result;
}
```
- [ ] **Step 5: Run the new tests to verify they pass**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~ChangePassword"`
Expected: 4 tests pass (`ChangePassword_ValidRequest_Succeeds`, `ChangePassword_UnknownUser_Fails`, `ChangePassword_WrongCurrentPassword_ReturnsFailure`, `ChangePassword_Success_RevokesOtherSessionsButKeepsCurrent`).
- [ ] **Step 6: Run the full backend suite to confirm nothing regressed**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
Expected: all tests pass.
- [ ] **Step 7: Commit**
```bash
git add API/ROLAC.API/Services/IAuthService.cs API/ROLAC.API/Services/AuthService.cs API/ROLAC.API.Tests/Services/AuthServiceTests.cs
git commit -m "feat(auth): add ChangePasswordAsync with other-session revocation and audit"
```
---
## Task 3: Add the `POST /api/auth/change-password` controller endpoint
**Files:**
- Modify: `API/ROLAC.API/Controllers/AuthController.cs`
This codebase unit-tests services, not controllers, so this thin pass-through has no unit test; it is covered by Task 2's service tests and verified by build + the manual smoke test in Task 9.
- [ ] **Step 1: Add the endpoint**
In `API/ROLAC.API/Controllers/AuthController.cs`, add this action after the `Logout` action (after line 155), before the "Private helpers" region. The needed usings already exist: `System.Security.Claims` (line 1), `Microsoft.AspNetCore.Authorization` (line 2), `ROLAC.API.DTOs.Auth` (line 5).
```csharp
// -------------------------------------------------------------------------
// POST /api/auth/change-password
// -------------------------------------------------------------------------
/// <summary>
/// Changes the current user's password. Requires the correct current password and a
/// new password meeting the configured policy. On success the user's *other* sessions
/// are revoked while the current session stays active.
/// </summary>
[HttpPost("change-password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
if (string.IsNullOrEmpty(userId))
return Unauthorized();
var currentRefresh = Request.Cookies[CookieName];
var result = await _authService.ChangePasswordAsync(
userId, request.CurrentPassword, request.NewPassword, currentRefresh);
if (!result.Succeeded)
return BadRequest(new
{
message = string.Join(" ", result.Errors.Select(error => error.Description)),
});
return NoContent();
}
```
- [ ] **Step 2: Build to verify it compiles**
Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release`
Expected: Build succeeded (0 errors). If `Select` is unresolved, add `using System.Linq;` at the top (it is usually implicit via `ImplicitUsings`).
- [ ] **Step 3: Commit**
```bash
git add API/ROLAC.API/Controllers/AuthController.cs
git commit -m "feat(auth): add POST /api/auth/change-password endpoint"
```
---
## Task 4: Add password validators (TDD)
**Files:**
- Create: `APP/src/app/features/account/validators/password.validators.ts`
- Test: `APP/src/app/features/account/validators/password.validators.spec.ts`
- [ ] **Step 1: Write the failing tests**
Create `APP/src/app/features/account/validators/password.validators.spec.ts`:
```typescript
import { FormControl, FormGroup } from '@angular/forms';
import { passwordStrengthValidator, passwordMatchValidator } from './password.validators';
describe('passwordStrengthValidator', () => {
const validate = (value: string) =>
passwordStrengthValidator()(new FormControl(value));
it('returns null for an empty value (required handles emptiness)', () => {
expect(validate('')).toBeNull();
});
it('returns null for a strong password', () => {
expect(validate('Str0ng!Pass')).toBeNull();
});
it('flags a password that is too short', () => {
const errors = validate('Ab1!');
expect(errors?.['passwordStrength']?.['minlength']).toBeTrue();
});
it('flags a missing uppercase letter', () => {
const errors = validate('weak1234!');
expect(errors?.['passwordStrength']?.['uppercase']).toBeTrue();
});
it('flags a missing special character', () => {
const errors = validate('Weak1234');
expect(errors?.['passwordStrength']?.['special']).toBeTrue();
});
});
describe('passwordMatchValidator', () => {
const buildGroup = (current: string, next: string, confirm: string) =>
new FormGroup({
currentPassword: new FormControl(current),
newPassword: new FormControl(next),
confirmPassword: new FormControl(confirm),
});
it('returns null when new matches confirm and differs from current', () => {
const group = buildGroup('Old1234!', 'New1234!', 'New1234!');
expect(passwordMatchValidator()(group)).toBeNull();
});
it('flags a confirm mismatch', () => {
const group = buildGroup('Old1234!', 'New1234!', 'Different1!');
expect(passwordMatchValidator()(group)?.['mismatch']).toBeTrue();
});
it('flags a new password equal to the current password', () => {
const group = buildGroup('Same1234!', 'Same1234!', 'Same1234!');
expect(passwordMatchValidator()(group)?.['sameAsCurrent']).toBeTrue();
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: FAIL — `password.validators` module not found / functions undefined.
- [ ] **Step 3: Implement the validators**
Create `APP/src/app/features/account/validators/password.validators.ts`:
```typescript
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
/**
* Mirrors the ASP.NET Identity password policy enforced on the server:
* at least 8 characters with an uppercase, a lowercase, a digit, and a
* non-alphanumeric character. Client-side only — the server stays authoritative.
* Returns null for an empty value so the `required` validator owns emptiness.
*/
export function passwordStrengthValidator(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = control.value as string;
if (!value) {
return null;
}
const errors: ValidationErrors = {};
if (value.length < 8) {
errors['minlength'] = true;
}
if (!/[A-Z]/.test(value)) {
errors['uppercase'] = true;
}
if (!/[a-z]/.test(value)) {
errors['lowercase'] = true;
}
if (!/[0-9]/.test(value)) {
errors['digit'] = true;
}
if (!/[^a-zA-Z0-9]/.test(value)) {
errors['special'] = true;
}
return Object.keys(errors).length ? { passwordStrength: errors } : null;
};
}
/**
* Group-level validator: the confirm field must match the new password, and the
* new password must differ from the current one.
*/
export function passwordMatchValidator(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const current = group.get('currentPassword')?.value;
const next = group.get('newPassword')?.value;
const confirm = group.get('confirmPassword')?.value;
const errors: ValidationErrors = {};
if (next && confirm && next !== confirm) {
errors['mismatch'] = true;
}
if (next && current && next === current) {
errors['sameAsCurrent'] = true;
}
return Object.keys(errors).length ? errors : null;
};
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: PASS — the `passwordStrengthValidator` and `passwordMatchValidator` describe blocks are green.
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/features/account/validators/password.validators.ts APP/src/app/features/account/validators/password.validators.spec.ts
git commit -m "feat(account): add password strength and match validators"
```
---
## Task 5: Add `changePassword()` to the frontend AuthService (TDD)
**Files:**
- Modify: `APP/src/app/shared/services/auth.service.ts`
- Test: `APP/src/app/shared/services/auth.service.spec.ts`
- [ ] **Step 1: Write the failing test**
In `APP/src/app/shared/services/auth.service.spec.ts`, add this describe block inside the top-level `describe('AuthService', ...)` (e.g. after the `login()` block). The `service`, `httpMock`, and `apiConfig` variables are already set up in the file's `beforeEach`.
```typescript
// ── changePassword() ─────────────────────────────────────────────────────
describe('changePassword()', () => {
it('POSTs current+new password to /api/auth/change-password with credentials', () => {
service.changePassword('Old1234!', 'New1234!').subscribe();
const req = httpMock.expectOne(`${apiConfig.authUrl}/change-password`);
expect(req.request.method).toBe('POST');
expect(req.request.body).toEqual({
currentPassword: 'Old1234!',
newPassword: 'New1234!',
});
expect(req.request.withCredentials).toBeTrue();
req.flush(null, { status: 204, statusText: 'No Content' });
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: FAIL — `service.changePassword` is not a function.
- [ ] **Step 3: Implement the method**
In `APP/src/app/shared/services/auth.service.ts`, add this method inside the `AuthService` class in the "Auth API calls" region (e.g. after `logout()`, around line 164):
```typescript
/**
* Changes the current user's password. Sends the cookie so the server can
* keep the current session alive while revoking the user's other sessions.
* Emits void on success (204); errors propagate so the caller can show the
* server message.
*/
changePassword(currentPassword: string, newPassword: string): Observable<void> {
return this.http.post<void>(
`${this.apiConfig.authUrl}/change-password`,
{ currentPassword, newPassword },
{ withCredentials: true }
);
}
```
- [ ] **Step 4: Run the test to verify it passes**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: PASS — the `changePassword()` block is green.
- [ ] **Step 5: Commit**
```bash
git add APP/src/app/shared/services/auth.service.ts APP/src/app/shared/services/auth.service.spec.ts
git commit -m "feat(auth): add changePassword() to frontend AuthService"
```
---
## Task 6: Build the `ChangePasswordFormComponent` (TDD)
**Files:**
- Create: `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts`
- Create: `APP/src/app/features/account/components/change-password-form/change-password-form.component.html`
- Test: `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts`
- [ ] **Step 1: Write the failing tests**
Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.spec.ts`:
```typescript
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { ChangePasswordFormComponent } from './change-password-form.component';
import { AuthService } from '../../../../shared/services/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
describe('ChangePasswordFormComponent', () => {
let fixture: ComponentFixture<ChangePasswordFormComponent>;
let component: ChangePasswordFormComponent;
let authSpy: jasmine.SpyObj<AuthService>;
let toastSpy: jasmine.SpyObj<ToastService>;
beforeEach(async () => {
authSpy = jasmine.createSpyObj<AuthService>('AuthService', ['changePassword']);
toastSpy = jasmine.createSpyObj<ToastService>('ToastService', ['success', 'error']);
await TestBed.configureTestingModule({
imports: [ChangePasswordFormComponent],
providers: [
{ provide: AuthService, useValue: authSpy },
{ provide: ToastService, useValue: toastSpy },
],
}).compileComponents();
fixture = TestBed.createComponent(ChangePasswordFormComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
const fill = (current: string, next: string, confirm: string) => {
component.form.setValue({
currentPassword: current,
newPassword: next,
confirmPassword: confirm,
});
};
it('is invalid when the new password is weak', () => {
fill('Old1234!', 'weak', 'weak');
expect(component.form.invalid).toBeTrue();
});
it('is invalid when confirm does not match', () => {
fill('Old1234!', 'New1234!', 'Other1234!');
expect(component.form.invalid).toBeTrue();
});
it('is invalid when the new password equals the current password', () => {
fill('Same1234!', 'Same1234!', 'Same1234!');
expect(component.form.invalid).toBeTrue();
});
it('is valid for a strong, matching, different new password', () => {
fill('Old1234!', 'New1234!', 'New1234!');
expect(component.form.valid).toBeTrue();
});
it('does not call the service when submitting an invalid form', () => {
fill('Old1234!', 'weak', 'weak');
component.onSubmit();
expect(authSpy.changePassword).not.toHaveBeenCalled();
});
it('calls the service with current+new and shows success + resets on 204', () => {
authSpy.changePassword.and.returnValue(of(void 0));
fill('Old1234!', 'New1234!', 'New1234!');
component.onSubmit();
expect(authSpy.changePassword).toHaveBeenCalledWith('Old1234!', 'New1234!');
expect(toastSpy.success).toHaveBeenCalled();
expect(component.form.get('newPassword')?.value).toBeNull();
});
it('shows the server error message on failure', () => {
authSpy.changePassword.and.returnValue(
throwError(() => ({ error: { message: 'Incorrect password.' } }))
);
fill('Wrong1234!', 'New1234!', 'New1234!');
component.onSubmit();
expect(toastSpy.error).toHaveBeenCalledWith('Incorrect password.');
});
});
```
- [ ] **Step 2: Run the tests to verify they fail**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: FAIL — `ChangePasswordFormComponent` module not found.
- [ ] **Step 3: Implement the component**
Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.ts`:
```typescript
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { InputsModule } from '@progress/kendo-angular-inputs';
import { LabelModule } from '@progress/kendo-angular-label';
import { ButtonsModule } from '@progress/kendo-angular-buttons';
import { AuthService } from '../../../../shared/services/auth.service';
import { ToastService } from '../../../../core/services/toast.service';
import {
passwordStrengthValidator,
passwordMatchValidator,
} from '../../validators/password.validators';
@Component({
selector: 'app-change-password-form',
standalone: true,
imports: [
CommonModule, ReactiveFormsModule,
InputsModule, LabelModule, ButtonsModule,
],
templateUrl: './change-password-form.component.html',
})
export class ChangePasswordFormComponent {
form: FormGroup;
submitting = false;
constructor(
private fb: FormBuilder,
private authService: AuthService,
private toast: ToastService,
) {
this.form = this.fb.group(
{
currentPassword: ['', [Validators.required]],
newPassword: ['', [Validators.required, passwordStrengthValidator()]],
confirmPassword: ['', [Validators.required]],
},
{ validators: passwordMatchValidator() },
);
}
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.submitting = true;
const { currentPassword, newPassword } = this.form.value;
this.authService.changePassword(currentPassword, newPassword).subscribe({
next: () => {
this.toast.success('Password changed successfully.');
this.form.reset();
this.submitting = false;
},
error: (err) => {
this.toast.error(err?.error?.message || 'Failed to change password.');
this.submitting = false;
},
});
}
}
```
- [ ] **Step 4: Create the template**
Create `APP/src/app/features/account/components/change-password-form/change-password-form.component.html`:
```html
<form [formGroup]="form" class="k-form k-form-vertical" (ngSubmit)="onSubmit()">
<div class="grid grid-cols-1 gap-y-3 max-w-md">
<kendo-formfield>
<kendo-label text="Current Password *"></kendo-label>
<kendo-textbox formControlName="currentPassword" type="password"
[clearButton]="false"></kendo-textbox>
<kendo-formerror *ngIf="form.get('currentPassword')?.errors?.['required']">
Required.
</kendo-formerror>
</kendo-formfield>
<kendo-formfield>
<kendo-label text="New Password *"></kendo-label>
<kendo-textbox formControlName="newPassword" type="password"
[clearButton]="false"></kendo-textbox>
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['required']">
Required.
</kendo-formerror>
<kendo-formerror *ngIf="form.get('newPassword')?.errors?.['passwordStrength']">
Must be at least 8 characters with an uppercase letter, a lowercase letter,
a digit, and a special character.
</kendo-formerror>
</kendo-formfield>
<kendo-formfield>
<kendo-label text="Confirm New Password *"></kendo-label>
<kendo-textbox formControlName="confirmPassword" type="password"
[clearButton]="false"></kendo-textbox>
<kendo-formerror *ngIf="form.get('confirmPassword')?.errors?.['required']">
Required.
</kendo-formerror>
<kendo-formerror *ngIf="form.errors?.['mismatch'] && form.get('confirmPassword')?.touched">
Passwords do not match.
</kendo-formerror>
<kendo-formerror *ngIf="form.errors?.['sameAsCurrent'] && form.get('newPassword')?.touched">
New password must be different from the current password.
</kendo-formerror>
</kendo-formfield>
<div class="mt-2">
<button kendoButton themeColor="primary" type="submit"
[disabled]="form.invalid || submitting">
Change Password
</button>
</div>
</div>
</form>
```
- [ ] **Step 5: Run the tests to verify they pass**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: PASS — all `ChangePasswordFormComponent` specs green.
- [ ] **Step 6: Commit**
```bash
git add APP/src/app/features/account/components/change-password-form/
git commit -m "feat(account): add ChangePasswordFormComponent"
```
---
## Task 7: Build the Account Settings page, route, and menu wiring
**Files:**
- Create: `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts`
- Create: `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html`
- Modify: `APP/src/app/app.routes.ts`
- Modify: `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts`
- [ ] **Step 1: Create the page component**
Create `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts`:
```typescript
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ChangePasswordFormComponent } from '../../components/change-password-form/change-password-form.component';
@Component({
selector: 'app-account-settings-page',
standalone: true,
imports: [CommonModule, ChangePasswordFormComponent],
templateUrl: './account-settings-page.component.html',
})
export class AccountSettingsPageComponent {}
```
- [ ] **Step 2: Create the page template**
Create `APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html`:
```html
<div class="p-4 md:p-6">
<section class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 max-w-xl">
<h2 class="text-lg font-semibold mb-1">Change Password</h2>
<p class="text-sm text-gray-500 mb-4">
Changing your password signs you out on your other devices.
</p>
<app-change-password-form></app-change-password-form>
</section>
</div>
```
- [ ] **Step 3: Register the route**
In `APP/src/app/app.routes.ts`, add an import near the other page-component imports (after line 25):
```typescript
import { AccountSettingsPageComponent } from './features/account/pages/account-settings-page/account-settings-page.component';
```
Then add this route inside the `user-portal` `children` array (e.g. right after the `dashboard` route block, around line 48). No `PermissionGuard` — any authenticated user may change their own password; the parent `AuthGuard` already protects it:
```typescript
{
path: 'account',
component: AccountSettingsPageComponent,
data: { title: 'Account Settings', titleZh: '帳戶設定', section: 'Account' },
},
```
- [ ] **Step 4: Wire the "Settings" menu item to the page**
In `APP/src/app/portals/user-portal/components/user-header/user-header.component.ts`, in `updateUserMenu()` (lines 100-104), change the disabled Settings entry to navigate to the account page. Replace:
```typescript
{
text: 'Settings',
icon: 'settings',
disabled: true
},
```
with:
```typescript
{
text: 'Settings',
icon: 'settings',
click: () => this.router.navigate(['/user-portal/account'])
},
```
(`this.router` is already injected in the constructor at line 50, and `onUserMenuClick` already invokes `item.click`.)
- [ ] **Step 5: Build the frontend to verify it compiles**
Run from `APP/`: `npx ng build`
Expected: Build completes with no errors.
- [ ] **Step 6: Run the full frontend test suite**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: all tests pass.
- [ ] **Step 7: Commit**
```bash
git add APP/src/app/features/account/pages/ APP/src/app/app.routes.ts APP/src/app/portals/user-portal/components/user-header/user-header.component.ts
git commit -m "feat(account): add Account Settings page, route, and wire Settings menu item"
```
---
## Task 8: Final verification — full suites both layers
- [ ] **Step 1: Run the full backend suite**
Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release`
Expected: all tests pass.
- [ ] **Step 2: Run the full frontend suite**
Run from `APP/`: `npx ng test --watch=false --browsers=ChromeHeadless`
Expected: all tests pass.
---
## Task 9 (optional): Manual smoke test against the dev API
Only if you want end-to-end confidence beyond unit tests. Requires running the API from CLI (`-c Release` to dodge the VS Debug lock) and pointing the SPA at it (see `project_build_run_env`: dev admin `admin@rolac.org` / `Admin1234!`, CORS allows `http://localhost:4200`).
- [ ] **Step 1: Log in, change password, verify**
- Log in as the seeded admin.
- Open the user menu → **Settings** → confirm the Account Settings page loads with the Change Password form.
- Submit with a wrong current password → expect an inline/toast error ("Incorrect password.").
- Submit with the correct current password and a policy-valid new password → expect a success toast and the form to reset.
- Log in again with the new password to confirm it took effect.
- (Optional) Restore the original password afterward so the seed login still works.
---
## Self-review notes
- **Spec coverage:** endpoint + service (Task 2-3), policy enforcement via `UserManager.ChangePasswordAsync` (Task 2), revoke-others-keep-current (Task 2 + test), audit entry (Task 1-2), `/user-portal/account` page + `ChangePasswordFormComponent` + Settings wiring (Task 6-7), `authService.changePassword` (Task 5), backend + frontend tests (throughout). All spec sections map to a task.
- **No DB migration** — confirmed: uses inherited Identity password fields and the existing `RefreshToken` table.
- **Type consistency:** `ChangePasswordAsync(userId, currentPassword, newPassword, currentRawRefreshToken)` signature is identical in interface (Task 2 Step 3), implementation (Step 4), and controller call (Task 3). Validator names `passwordStrengthValidator`/`passwordMatchValidator` and error keys (`passwordStrength`, `mismatch`, `sameAsCurrent`) match across validator (Task 4), component (Task 6), and templates.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
# Change Password (Self-Service) — Design
**Date:** 2026-06-23
**Status:** Approved, pending implementation plan
## Summary
Add a self-service "change password" feature so authenticated users can change
their own password. The UI lives on a new **Account Settings** page in the user
portal, reachable from the user-header menu's currently-disabled **Settings**
item. The backend exposes a new authenticated endpoint that verifies the current
password, enforces the existing ASP.NET Identity password policy, and — on
success — revokes the user's *other* sessions while keeping the current one
active.
Out of scope (explicitly deferred): admin-driven forced password change /
"must change on first login" after an admin reset. The existing admin reset
endpoint (`POST /api/users/{id}/reset-password`) is unchanged.
## Existing infrastructure (context)
- **User entity:** `API/ROLAC.API/Entities/AppUser.cs``IdentityUser`, so
`PasswordHash` / `SecurityStamp` are inherited.
- **Hashing & policy:** ASP.NET Core Identity (`PasswordHasher<AppUser>`, PBKDF2).
Policy in `API/ROLAC.API/Program.cs`: min length 8, requires digit, uppercase,
lowercase, and non-alphanumeric.
- **Auth service / controller:** `API/ROLAC.API/Services/AuthService.cs`,
`API/ROLAC.API/Controllers/AuthController.cs` (`/api/auth/login`, `/refresh`,
`/me`, `/logout`). Refresh tokens stored in DB; the active refresh token is
delivered in an HttpOnly cookie.
- **Current user id:** read from the `sub` JWT claim
(`ClaimTypes.NameIdentifier ?? "sub"`), because `MapInboundClaims = false` and
`NameClaimType = "sub"`.
- **Audit:** `API/ROLAC.API/Services/Logging/AuditLogger.cs` — security actions
(login success/failure, logout) are logged; password change will be too.
- **Frontend auth:** `APP/src/app/shared/services/auth.service.ts`.
- **User portal:** `APP/src/app/portals/user-portal/` with
`components/user-header/user-header.component.ts` (user menu with disabled
Profile/Settings stubs). Routes in `APP/src/app/app.routes.ts` carry
`title`/`titleZh`/`section` data for the unified system header.
- **Form patterns:** `APP/src/app/features/users/components/edit-user-dialog/`
(Kendo `kendo-textbox`, `kendo-formfield`, `kendo-formerror`, Reactive Forms).
Form layout via Tailwind utilities on a neutral wrapper, not per-component SCSS.
## Backend
### Endpoint
`POST /api/auth/change-password` — requires authentication.
```csharp
public record ChangePasswordRequest(string CurrentPassword, string NewPassword);
```
### Flow (`AuthService.ChangePasswordAsync`)
1. Resolve the user id from the `sub` claim; `UserManager.FindByIdAsync`.
2. `UserManager.ChangePasswordAsync(user, currentPassword, newPassword)`. This:
- verifies the current password,
- enforces the configured Identity password policy on the new password,
- re-hashes and persists,
- automatically bumps `SecurityStamp`.
No manual `CheckPasswordAsync` call is needed.
3. On failure, return `400 Bad Request` with readable error messages:
- wrong current password → "Incorrect current password",
- policy failures → mapped to readable messages.
4. On success:
- revoke all of the user's refresh tokens **except** the one presented in the
current request's HttpOnly cookie (other devices get logged out; current
session stays alive),
- write a security audit-log entry (same pattern as login logging:
action, category Security, entityId = user id, user email, IP).
5. Return `204 No Content`.
The controller reads the current refresh token from the cookie to identify which
session to preserve, and passes it to the service. Validation is
server-authoritative; client-side checks are UX-only.
## Frontend
### Route & navigation
- New route `/user-portal/account``AccountSettingsPageComponent`, registered
in `app.routes.ts` with `[AuthGuard]` and `data: { title: 'Account Settings',
titleZh: '帳戶設定', section: 'Account' }` so it uses the unified system header.
- Wire the disabled **Settings** item in `user-header.component.ts` to navigate
to this route (remove `disabled`, add navigation). The **Profile** stub is left
as-is.
### Components
- `AccountSettingsPageComponent` — page shell hosting a "Change Password"
section/card. Room to grow later (profile, language preference).
- `ChangePasswordFormComponent` — focused child component owning the form
(single responsibility; independently testable).
### Form
- Reactive Forms + Kendo, mirroring `edit-user-dialog` patterns.
- Three `kendo-textbox type="password"` fields: Current password, New password,
Confirm new password.
- Layout via Tailwind utilities (`grid grid-cols-1`) on a neutral wrapper div —
no per-component SCSS for layout.
- Validators (UX-only; server stays authoritative):
- new password: min 8, at least one upper, lower, digit, non-alphanumeric,
- cross-field: new ≠ current, confirm === new.
- Show password-rule hints and `kendo-formerror` messages. Submit disabled while
invalid or pending.
- Submit → `authService.changePassword(current, next)`:
- `204` → success notification, reset form,
- `400` → surface server message inline (e.g. incorrect current password).
- Single narrow column → inherently mobile-friendly; no grid/card-list split.
### Auth service
Add `changePassword(currentPassword, newPassword): Observable<void>` calling
`POST /api/auth/change-password` with `withCredentials: true` so the
refresh-token cookie is sent.
## Testing
Follow TDD — tests first, then implementation — for both layers.
### Backend (xUnit; build with `-c Release` per build env notes)
- Success: correct current password + policy-valid new password → succeeds,
stored hash changes, `204`.
- Wrong current password → `400`, password unchanged.
- New password failing policy (too short / missing a required class) → `400`
with the relevant message.
- Other refresh tokens revoked; the current cookie's refresh token preserved.
- Audit entry written.
### Frontend (Karma/Jasmine)
- `ChangePasswordFormComponent`: validators (weak password invalid, mismatch
invalid, new === current invalid); submit disabled when invalid; calls service
with correct args; renders server error on `400`; resets on success.
- `AuthService.changePassword`: issues `POST /api/auth/change-password` with
`withCredentials`.
## Deliverables checklist
- Backend: `ChangePasswordRequest` DTO, `AuthController` action, `AuthService`
method, refresh-token revocation (preserve current), audit logging.
- Frontend: `/user-portal/account` route, `AccountSettingsPageComponent`,
`ChangePasswordFormComponent`, Settings menu wiring, `authService.changePassword`.
- Tests: backend unit tests + frontend unit tests.
@@ -0,0 +1,293 @@
# Notification Service (Email + Line) — 設計文件
- **日期**2026-06-23
- **狀態**:已核可(待實作計畫)
- **作者**Chris Chen + Claude
- **專案**ROLACRiver Of Life Christian Church 教會管理系統)
- **前置文件**[2026-06-20-line-notifications-design.md](2026-06-20-line-notifications-design.md)Line 模組)、[../../NOTIFICATIONS.md](../../NOTIFICATIONS.md)(早期多通道願景)
---
## 1. 背景與目標
需要一個**後端通知能力**,讓後端程式碼可以決定透過 **Email****Line** 發送訊息:
- **Email**:正式內容(年度奉獻收據含 PDF、歡迎信、密碼重設等)。每位會友 `Member.Email` 已存在,無需綁定。
- **Line**:非正式提醒,介於 Email(太正式)與 SMS(不適合)之間;可發給個人或群組。需先綁定取得 Line `userId`
本次將 2026-06-20 已核可的 **Line 模組**完整實作,並**新增 Email 作為第二通道**,兩者共用 `NotificationLog` 稽核表。
### 決策摘要(brainstorming 結論)
| 決策 | 選擇 |
|---|---|
| 範圍 | 完整 Line 模組 **+** Email 第二通道,一次完成 |
| 本階段平台 | **僅 API 端**(不做 Angular 前端;Line spec 的 UI 延後) |
| Email 傳輸 | **SMTP**(使用 **MailKit / MimeKit** |
| 路由決策 | **後端程式碼決定**每次發送走 Email 或 Line |
| Email 內容 | Subject + HTML body + 可選附件;**呼叫端自行組好最終 body**(本階段不做範本引擎) |
| 服務切分 | **兩個對等服務**(非單一 facade):`IEmailService` + `ILineNotificationService` |
### 為什麼 Email 不塞進 `IMessageChannel`
Line spec 的 `IMessageChannel` 是聊天形狀(`PushToUserAsync(externalId, text)` / `ReplyAsync(replyToken, text)`),而 Email 有 subject、HTML、附件、收件地址、無 reply token、無群組概念。強行共用會造成糟糕的抽象。故 Email 走獨立的 `IEmailService`
---
## 2. 架構總覽
```
後端程式碼(receipts / welcome / reminders ...
│ 直接呼叫對應服務(後端決定通道)
┌──────────────┴───────────────┐
▼ ▼
IEmailService ILineNotificationService
(EmailService) (LineNotificationService)
MailKit / SMTP 收件人解析 + 稽核
subject / HTML / 附件 │
│ ▼
│ IMessageChannel
│ (LineMessageChannel) ← 沿用核可 spec
│ Push / Reply REST
▼ │
└──────────► NotificationLog ◄──┘ (共用稽核表,channel 區分)
LineWebhookController(匿名、HMAC 驗簽)──► 綁定 + 群組註冊(多用 reply 省額度)
NotificationsControlleradmin [Authorize])──► 綁定碼 / 群組 / 歷史 / 手動發送
```
- **兩個對等服務**,後端依用途自行呼叫。
- 兩者皆寫 `NotificationLog``Channel` 欄位區分 `email` / `line`)。
- Line 對內接收(webhook)與綁定流程沿用核可 spec。
---
## 3. 資料模型(新實體)
Line 三表沿用核可 spec`NotificationLog` 調整為同時服務兩通道。命名與軟刪除慣例對齊既有實體。
### `MemberChannelBinding`
會友與通道帳號的綁定(獨立表,未來多通道不需改 `Member`)。
| 欄位 | 型別 | 說明 |
|---|---|---|
| Id | int | PK |
| MemberId | int FK → Member | |
| Channel | string | "line"(未來 "wechat"/"webpush" |
| ExternalId | string | Line userId |
| BoundAt | DateTime | 綁定時間 |
- 唯一索引:`(MemberId, Channel)``(Channel, ExternalId)`
### `LineBindingCode`
短效綁定碼(會友在 Line 輸入此碼以完成綁定)。
| 欄位 | 型別 | 說明 |
|---|---|---|
| Id | int | PK |
| Code | string | 短碼(避免易混淆字元) |
| MemberId | int FK → Member | |
| ExpiresAt | DateTime | 過期時間(建議 15 分鐘) |
| ConsumedAt | DateTime? | 已使用時間(null = 未用) |
### `MessagingGroup`
bot 被拉進的 Line 群組。
| 欄位 | 型別 | 說明 |
|---|---|---|
| Id | int | PK |
| Channel | string | "line" |
| ExternalId | string | Line groupId |
| Name | string? | 管理員命名(join 時先空白待命名) |
| IsActive | bool | leave 事件時設 false |
| RegisteredAt | DateTime | |
- 唯一索引:`(Channel, ExternalId)`
### `NotificationLog`(共用:Email + Line
每筆發送的稽核記錄。
| 欄位 | 型別 | 說明 |
|---|---|---|
| Id | int | PK |
| Channel | string | "line" / "email" |
| TargetType | string | "user" / "group" / "email" |
| TargetExternalId | string | Line id **或** email 地址 |
| Subject | string? | **新增**Email 主旨(Line 為 null |
| MemberId | int? FK | 由會友解析時填 |
| MessagingGroupId | int? FK | Line 群組發送時填 |
| Body | string | 訊息內容(Email 存 HTML body,過長時截斷至合理上限,例如 8000 字元,並標註截斷) |
| Status | string | "sent" / "failed" |
| Error | string? | 失敗原因 |
| SentByUserId | string | 發送者(JWT sub claim,使用 `?? "sub"` fallback;背景觸發時填 "system" |
| SentAt | DateTime | |
- EF migration 以 `-c Release` / `--output` 建置(VS 鎖 `bin/Debug`)。
### `ScheduledNotification`Phase 2 預留,本階段不建表)
Body、收件人規格、RunAtUtc、Recurrence、Status。
---
## 4. 服務介面與流程
### 4.1 `IEmailService`(獨立,MailKit/SMTP
```csharp
public interface IEmailService
{
Task<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default);
}
public sealed record EmailMessage(
IReadOnlyList<int> MemberIds, // 解析 Member.Email
IReadOnlyList<string> Addresses, // 直接指定的收件地址
string Subject,
string HtmlBody, // 呼叫端組好的最終 HTML
IReadOnlyList<EmailAttachment>? Attachments = null,
string? SentByUserId = null);
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
```
**流程**
1. 解析 `MemberIds``Member.Email`(跳過 null/空白),與 `Addresses` 去重合併。
2. 逐一以 MailKit 發送(subject + HTML + 附件)。
3. 每位收件人寫一筆 `NotificationLog``channel=email``sent`/`failed`)。
4. 回傳 `NotificationResult` 彙總;**單一收件人失敗不丟例外**,記錄後續行。
5. SMTP 連線/驗證層級錯誤 → 寫 SystemLog,並把該批標記 failed。
**MailKit seam**:實際 `SmtpClient.Connect/Authenticate/Send` 封裝在薄介面(例如 `ISmtpDispatcher`)後,使 `EmailService` 的收件人解析、附件對應、log 寫入可單元測試(不需真實 SMTP server)。
### 4.2 `ILineNotificationService`(沿用核可 spec
```csharp
public interface ILineNotificationService
{
Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
string sentByUserId, CancellationToken ct = default);
Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default);
}
```
**`SendLineAsync` 流程**:解析收件人 → 已綁定會友 `ExternalId` + 啟用群組 `ExternalId`;逐一呼叫 `IMessageChannel`;每筆寫 `NotificationLog``channel=line`);回傳彙總。
### 4.3 共用回傳型別
```csharp
public sealed record NotificationResult(
int SentCount, int FailedCount, IReadOnlyList<NotificationFailure> Failures);
public sealed record NotificationFailure(string Target, string Error);
```
### 4.4 `IMessageChannel` / `LineMessageChannel`(沿用核可 spec
- `PushToUserAsync(externalId, text)``PushToGroupAsync(externalId, text)``ReplyAsync(replyToken, text)`
- 封裝 Line REST`HttpClient``IHttpClientFactory`token 讀 config。回傳成功/失敗供上層寫 log。
### 4.5 `LineWebhookController`(路由 `/api/line/webhook`,匿名)
1. 讀**原始 body**,以 `Line:ChannelSecret` 計算 HMAC-SHA256,與 `X-Line-Signature` 比對;不符回 400。
2. 快速回 200,事件分派:
- **follow**:記 userIdreply 綁定說明(請輸入綁定碼)。
- **message**(文字):比對有效 `LineBindingCode` → 建 `MemberChannelBinding`、標記 code 已用、reply 成功;無對應碼則 reply 說明。重複綁定 → 更新既有 binding。
- **join**:建 `MessagingGroup`(Name 待命名);reply 提示後台命名。
- **leave**:對應 `MessagingGroup``IsActive=false`
### 4.6 `NotificationsController`admin `[Authorize]`
本階段無前端,這些端點供管理員(Swagger)操作與測試發送:
- `POST /api/notifications/members/{id}/line-binding-code` — 產生綁定碼。
- `GET /api/notifications/groups` / `PUT .../groups/{id}` — 群組列表 / 改名 / 啟停用。
- `GET /api/notifications/history``NotificationLog` 分頁(Email + Line)。
- `POST /api/notifications/send-line` — 手動發 Linebody + memberIds[] + groupIds[])。
- `POST /api/notifications/send-email` — 手動發 Emailsubject + htmlBody + memberIds[] + addresses[],附件本階段以後端程式為主要用途,端點先支援無附件)。
> 註:真正的程式化發送(收據、歡迎信等)由其他後端程式直接呼叫 `IEmailService` / `ILineNotificationService`;上述 send 端點主要供無 UI 階段的手動測試與管理員臨時發送。
---
## 5. 設定(config)與 DI
### 5.1 設定區段(secrets 走 user-secrets / 環境變數,勿入版控)
```jsonc
"Smtp": {
"Host": "smtp.example.com",
"Port": 587,
"UseSsl": true, // STARTTLS
"User": "${SMTP_USER}",
"Password": "${SMTP_PASSWORD}",
"FromAddress": "noreply@rolac.org",
"FromName": "River of Life Christian Church"
},
"Line": {
"ChannelAccessToken": "${LINE_CHANNEL_ACCESS_TOKEN}",
"ChannelSecret": "${LINE_CHANNEL_SECRET}"
}
```
### 5.2 DI[Program.cs](../../../API/ROLAC.API/Program.cs)
- `builder.Services.Configure<SmtpOptions>(config.GetSection("Smtp"))``Configure<LineOptions>(...)`
- `AddScoped<IEmailService, EmailService>()`
- `AddScoped<ISmtpDispatcher, MailKitSmtpDispatcher>()`MailKit seam)。
- `AddScoped<ILineNotificationService, LineNotificationService>()`
- `AddScoped<IMessageChannel, LineMessageChannel>()` + `AddHttpClient`Line REST)。
- Webhook 為 server-to-serverCORS 不受影響。
---
## 6. 錯誤處理
- **Webhook 簽章不符** → 400 並記錄;合法事件一律快速回 200(避免 Line 重送)。
- **Email 發送失敗**(無效地址、SMTP 拒絕)→ 該筆 `NotificationLog` `failed` + error;連線/驗證層級錯誤額外寫 SystemLog;不因單筆失敗中斷整批。
- **Line 推播失敗**(封鎖、id 失效、**額度用罄**)→ `NotificationLog` `failed` + error,回報彙總。
- **綁定碼**:過期 / 已使用 / 不存在 → reply 友善說明;重複綁定 → 更新既有 binding。
- **發送者歸屬**JWT sub claim 用 `?? "sub"` fallback;背景觸發填 "system"。
---
## 7. 測試(xUnit`-c Release` / `--output` 建置)
- **Email**:收件人解析(`MemberIds`→Email、跳過 null/空白、與 `Addresses` 去重);附件對應;單筆失敗不中斷整批;log 寫入與彙總(`ISmtpDispatcher` mock)。
- **Line 簽章**HMAC-SHA256 正/負案例。
- **綁定碼**:有效 / 過期 / 已使用 / 不存在;重複綁定更新。
- **收件人解析**:未綁定會友、停用群組被略過。
- **Line payload 組裝**mock `HttpMessageHandler`
- **Webhook 分派**follow / message / join / leave 樣本 payload。
- **彙總與 log**:成功 / 部分失敗。
---
## 8. 分階段範圍
### Phase 1(本次實作,僅 API 端)
- Email`IEmailService` + MailKit SMTP + 附件 + `NotificationLog`
- Linewebhook 綁定(個人+群組)+ 手動立即發送 + 綁定碼 + 群組管理 + 歷史,皆透過 API/Swagger。
- 兩服務共用 `NotificationLog`
### Phase 2
排程定時發送:`ScheduledNotification` + 背景 worker`BackgroundService` 或 Hangfire/Quartz,實作計畫時決定)。
### Phase 3
事件觸發(生日 / 活動報名確認 / 奉獻收據)整合既有模組;多通道擴充(PWA Web Push、WeChat);Email 範本引擎與雙語範本。
---
## 9. 使用者需先準備(非程式)
- **SMTP**:取得寄件信箱主機 / port / 帳密 / 寄件地址(M365、Google Workspace 或主機 SMTP);設定寄件網域 SPF/DKIM 以利送達率。
- **Line**:申請 **Line Official Account + Messaging API channel**,取得 **Channel access token****Channel secret**nginx 將 `/api/line/webhook` 對外路由,於 Line 後台填入 webhook URL 並啟用。
- 將 SMTP 與 Line secrets 放入 API 設定(user-secrets / 環境變數,勿入版控)。
---
## 10. 待解 / 未涵蓋(Out of scope
- Angular 前端(撰寫頁、歷史頁、綁定面板、群組管理頁)。
- 排程與事件觸發發送。
- Email 範本引擎與雙語範本(本階段呼叫端自行組 HTML)。
- 通知偏好設定矩陣(會友自選通道)。
- 圖文訊息、多語 Line 訊息。
- 推播額度自動監控與告警。
- 原生 App 推播 / PWA Web Push / SMS。