Compare commits
18 Commits
7356d0e810
...
4225b49e58
| Author | SHA1 | Date | |
|---|---|---|---|
| 4225b49e58 | |||
| 5a915ebdd1 | |||
| fd71f5a107 | |||
| 9405914d88 | |||
| 39432ac588 | |||
| 4c22cfaf19 | |||
| c8bc7103ba | |||
| 3eeb314dc2 | |||
| 0ddb34dd20 | |||
| 444cc70b56 | |||
| 85bf329d93 | |||
| 3544b6ee78 | |||
| 0e90f19377 | |||
| f9c4d7edb2 | |||
| b7372dec1f | |||
| 21e9823008 | |||
| 583408032d | |||
| ea0ea233a8 |
@@ -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);
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
- **專案**:ROLAC(River 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 省額度)
|
||||
NotificationsController(admin [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**:記 userId;reply 綁定說明(請輸入綁定碼)。
|
||||
- **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` — 手動發 Line(body + memberIds[] + groupIds[])。
|
||||
- `POST /api/notifications/send-email` — 手動發 Email(subject + 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-server,CORS 不受影響。
|
||||
|
||||
---
|
||||
|
||||
## 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`。
|
||||
- Line:webhook 綁定(個人+群組)+ 手動立即發送 + 綁定碼 + 群組管理 + 歷史,皆透過 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。
|
||||
Reference in New Issue
Block a user