diff --git a/API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs b/API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs new file mode 100644 index 0000000..da6d0dd --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs @@ -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 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(); + 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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task 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: "

Body

"); + + 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(), "Hi", "

Body

")); + + 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(), + new[] { "bad@example.com", "good@example.com" }, + "Hi", "

Body

")); + + 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)); + } +} diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs new file mode 100644 index 0000000..05692c5 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs @@ -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 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); + } +} diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs new file mode 100644 index 0000000..108a7bb --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs @@ -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 PushToUserAsync(string externalId, string text, CancellationToken ct = default) + { + UserPushes.Add((externalId, text)); + return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null)); + } + public Task PushToGroupAsync(string externalId, string text, CancellationToken ct = default) + { + GroupPushes.Add((externalId, text)); + return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null)); + } + public Task 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(); + 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() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task 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(), "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(), "admin-1"); + + Assert.Equal(0, result.SentCount); + Assert.Empty(channel.UserPushes); + } +} diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs new file mode 100644 index 0000000..a7e1203 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs @@ -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, "")); + } +} diff --git a/API/ROLAC.API/Controllers/LineWebhookController.cs b/API/ROLAC.API/Controllers/LineWebhookController.cs new file mode 100644 index 0000000..5a144a3 --- /dev/null +++ b/API/ROLAC.API/Controllers/LineWebhookController.cs @@ -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; + +/// +/// 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. +/// +[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 options) + { + _line = line; + _channel = channel; + _options = options.Value; + } + + [HttpPost("webhook")] + [RequestSizeLimit(262_144)] + public async Task 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(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; + } + } +} diff --git a/API/ROLAC.API/Controllers/NotificationsController.cs b/API/ROLAC.API/Controllers/NotificationsController.cs new file mode 100644 index 0000000..972d686 --- /dev/null +++ b/API/ROLAC.API/Controllers/NotificationsController.cs @@ -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; + +/// +/// 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. +/// +[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 GenerateBindingCode(int id, CancellationToken ct) + => Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) }); + + [HttpGet("groups")] + public async Task 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 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 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 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 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)); +} diff --git a/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs b/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs new file mode 100644 index 0000000..6d9b441 --- /dev/null +++ b/API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs @@ -0,0 +1,28 @@ +namespace ROLAC.API.DTOs.Notifications; + +/// Top-level Line webhook payload (deserialized case-insensitively). +public sealed class LineWebhookPayload +{ + public List? 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; } +} diff --git a/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs b/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs new file mode 100644 index 0000000..903954f --- /dev/null +++ b/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs @@ -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); diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 33448a7..af2cd67 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -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 public DbSet MealAttendances => Set(); public DbSet RolePermissions => Set(); + public DbSet MemberChannelBindings => Set(); + public DbSet LineBindingCodes => Set(); + public DbSet MessagingGroups => Set(); + public DbSet NotificationLogs => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); @@ -326,6 +332,49 @@ public class AppDbContext : IdentityDbContext entity.HasIndex(e => new { e.Year, e.Month }).IsUnique(); }); + // ── Notifications (email + Line) ───────────────────────────────────── + builder.Entity(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(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(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(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. diff --git a/API/ROLAC.API/Entities/Notifications/LineBindingCode.cs b/API/ROLAC.API/Entities/Notifications/LineBindingCode.cs new file mode 100644 index 0000000..3e559c3 --- /dev/null +++ b/API/ROLAC.API/Entities/Notifications/LineBindingCode.cs @@ -0,0 +1,14 @@ +using ROLAC.API.Entities; + +namespace ROLAC.API.Entities.Notifications; + +/// A short-lived code a member types to the Line bot to complete account binding. +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 +} diff --git a/API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs b/API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs new file mode 100644 index 0000000..6be24d9 --- /dev/null +++ b/API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs @@ -0,0 +1,17 @@ +using ROLAC.API.Entities; + +namespace ROLAC.API.Entities.Notifications; + +/// +/// Binds a member to an external channel account (e.g. a Line userId). Separate table so future +/// channels don't require changes to Member. +/// +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; } +} diff --git a/API/ROLAC.API/Entities/Notifications/MessagingGroup.cs b/API/ROLAC.API/Entities/Notifications/MessagingGroup.cs new file mode 100644 index 0000000..08ed417 --- /dev/null +++ b/API/ROLAC.API/Entities/Notifications/MessagingGroup.cs @@ -0,0 +1,12 @@ +namespace ROLAC.API.Entities.Notifications; + +/// A Line group the bot was added to. Named by an admin after the join event. +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; } +} diff --git a/API/ROLAC.API/Entities/Notifications/NotificationLog.cs b/API/ROLAC.API/Entities/Notifications/NotificationLog.cs new file mode 100644 index 0000000..2bf87b7 --- /dev/null +++ b/API/ROLAC.API/Entities/Notifications/NotificationLog.cs @@ -0,0 +1,22 @@ +using ROLAC.API.Entities; + +namespace ROLAC.API.Entities.Notifications; + +/// An append-only audit row for every email or Line send (success or failure). +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; } +} diff --git a/API/ROLAC.API/Migrations/20260624020251_AddNotifications.Designer.cs b/API/ROLAC.API/Migrations/20260624020251_AddNotifications.Designer.cs new file mode 100644 index 0000000..ecad867 --- /dev/null +++ b/API/ROLAC.API/Migrations/20260624020251_AddNotifications.Designer.cs @@ -0,0 +1,1902 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using ROLAC.API.Data; + +#nullable disable + +namespace ROLAC.API.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260624020251_AddNotifications")] + partial class AddNotifications + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LanguagePreference") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("en"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("MemberId") + .IsUnique() + .HasFilter("\"MemberId\" IS NOT NULL"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckDate") + .HasColumnType("date"); + + b.Property("CheckNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PayeeAddress") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PayeeCity") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PayeeName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PayeeState") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("PayeeType") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("PayeeZip") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ReceiptCapturedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ReceiptSignatureBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReceiptSignedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReceiptSignedName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Issued"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("VoidReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VoidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VoidedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CheckDate"); + + b.HasIndex("CheckNumber") + .IsUnique() + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("MemberId"); + + b.HasIndex("Status") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Checks"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.CheckLine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpenseId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("CheckId"); + + b.HasIndex("ExpenseId"); + + b.ToTable("CheckLines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ChurchProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BankAccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("BankName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BankRoutingNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NextCheckNumber") + .HasColumnType("integer"); + + b.Property("State") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZipCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id"); + + b.ToTable("ChurchProfiles"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Expense", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CategoryGroupId") + .HasColumnType("integer"); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ExpenseDate") + .HasColumnType("date"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("MinistryId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PaidAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PaidBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ReceiptBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewNotes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReviewedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(30) + .HasColumnType("character varying(30)") + .HasDefaultValue("Draft"); + + b.Property("SubCategoryId") + .HasColumnType("integer"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("VendorName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("CategoryGroupId"); + + b.HasIndex("ExpenseDate"); + + b.HasIndex("MemberId"); + + b.HasIndex("MinistryId"); + + b.HasIndex("Status") + .HasFilter("\"IsDeleted\" = false"); + + b.HasIndex("SubCategoryId"); + + b.ToTable("Expenses"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("ExpenseCategoryGroups"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GroupId") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("ExpenseSubCategories"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.FamilyUnit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasColumnType("text"); + + b.Property("FamilyName_en") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FamilyName_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("FamilyUnits"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Amount") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GivingCategoryId") + .HasColumnType("integer"); + + b.Property("GivingDate") + .HasColumnType("date"); + + b.Property("IsAnonymous") + .HasColumnType("boolean"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("OfferingSessionId") + .HasColumnType("integer"); + + b.Property("PayPalTransactionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PaymentMethod") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZelleReferenceCode") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("GivingCategoryId"); + + b.HasIndex("GivingDate"); + + b.HasIndex("OfferingSessionId") + .HasFilter("\"OfferingSessionId\" IS NOT NULL"); + + b.HasIndex("MemberId", "GivingDate"); + + b.ToTable("Givings"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.GivingCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Description_en") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Description_zh") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.ToTable("GivingCategories"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Logging.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Changes") + .HasColumnType("jsonb"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EntityName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("Summary") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Action"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId") + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("Category", "Timestamp"); + + b.HasIndex("EntityName", "EntityId"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Logging.SystemLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("CorrelationId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("EventId") + .HasColumnType("integer"); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("HttpMethod") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequestPath") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("Level"); + + b.HasIndex("Timestamp"); + + b.HasIndex("UserId") + .HasFilter("\"UserId\" IS NOT NULL"); + + b.HasIndex("Timestamp", "Level"); + + b.ToTable("SystemLogs", (string)null); + }); + + modelBuilder.Entity("ROLAC.API.Entities.MealAttendance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdultCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("AttendanceDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("KidCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("YouthCount") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("AttendanceDate") + .IsUnique(); + + b.ToTable("MealAttendances"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Member", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Address") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaptismChurch") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("BaptismDate") + .HasColumnType("date"); + + b.Property("City") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Country") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasDefaultValue("USA"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("FamilyUnitId") + .HasColumnType("integer"); + + b.Property("FirstName_en") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("FirstName_zh") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("JoinDate") + .HasColumnType("date"); + + b.Property("LanguagePreference") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasDefaultValue("en"); + + b.Property("LastName_en") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastName_zh") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("NickName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("PhoneCell") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PhoneHome") + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.Property("PhotoBlobPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("State") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Member"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("ZipCode") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .HasFilter("\"Email\" IS NOT NULL"); + + b.HasIndex("FamilyUnitId"); + + b.HasIndex("Status") + .HasFilter("\"IsDeleted\" = false"); + + b.ToTable("Members"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Ministry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description_en") + .HasColumnType("text"); + + b.Property("Description_zh") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name_en") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name_zh") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Ministries"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.MonthlyStatement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BankStatementBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CalculatedClosingBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Difference") + .HasColumnType("decimal(18,2)"); + + b.Property("FinalizedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinalizedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("IsFinalized") + .HasColumnType("boolean"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OpeningBalance") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalExpenses") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalGiving") + .HasColumnType("decimal(18,2)"); + + b.Property("TotalOtherIncome") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Year", "Month") + .IsUnique(); + + b.ToTable("MonthlyStatements"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("MessagingGroupId") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentByUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Subject") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TargetExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CashTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CheckTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Difference") + .HasColumnType("decimal(18,2)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ProofPdfPath") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReconciledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReconciledBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SessionDate") + .HasColumnType("date"); + + b.Property("Status") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(20) + .HasColumnType("character varying(20)") + .HasDefaultValue("Draft"); + + b.Property("SubmittedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SubmittedBy") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("SystemTotal") + .HasColumnType("decimal(18,2)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("SessionDate") + .IsUnique(); + + b.ToTable("OfferingSessions"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeviceInfo") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IpAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)"); + + b.Property("ReplacedByHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CanApprove") + .HasColumnType("boolean"); + + b.Property("CanDelete") + .HasColumnType("boolean"); + + b.Property("CanRead") + .HasColumnType("boolean"); + + b.Property("CanWrite") + .HasColumnType("boolean"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.Property("RoleId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId", "Module") + .IsUnique(); + + b.ToTable("RolePermissions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("ROLAC.API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Member"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.CheckLine", b => + { + b.HasOne("ROLAC.API.Entities.Check", "Check") + .WithMany("Lines") + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Expense", "Expense") + .WithMany() + .HasForeignKey("ExpenseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Check"); + + b.Navigation("Expense"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Expense", b => + { + b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "CategoryGroup") + .WithMany() + .HasForeignKey("CategoryGroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.Ministry", "Ministry") + .WithMany() + .HasForeignKey("MinistryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.ExpenseSubCategory", "SubCategory") + .WithMany() + .HasForeignKey("SubCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CategoryGroup"); + + b.Navigation("Member"); + + b.Navigation("Ministry"); + + b.Navigation("SubCategory"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseSubCategory", b => + { + b.HasOne("ROLAC.API.Entities.ExpenseCategoryGroup", "Group") + .WithMany("SubCategories") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Giving", b => + { + b.HasOne("ROLAC.API.Entities.GivingCategory", "GivingCategory") + .WithMany() + .HasForeignKey("GivingCategoryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ROLAC.API.Entities.OfferingSession", "OfferingSession") + .WithMany("Givings") + .HasForeignKey("OfferingSessionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("GivingCategory"); + + b.Navigation("Member"); + + b.Navigation("OfferingSession"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Member", b => + { + b.HasOne("ROLAC.API.Entities.FamilyUnit", "FamilyUnit") + .WithMany() + .HasForeignKey("FamilyUnitId") + .OnDelete(DeleteBehavior.SetNull); + + 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") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.RolePermission", b => + { + b.HasOne("ROLAC.API.Entities.AppRole", "Role") + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.AppUser", b => + { + b.Navigation("RefreshTokens"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.Check", b => + { + b.Navigation("Lines"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.ExpenseCategoryGroup", b => + { + b.Navigation("SubCategories"); + }); + + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => + { + b.Navigation("Givings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/ROLAC.API/Migrations/20260624020251_AddNotifications.cs b/API/ROLAC.API/Migrations/20260624020251_AddNotifications.cs new file mode 100644 index 0000000..d678ed3 --- /dev/null +++ b/API/ROLAC.API/Migrations/20260624020251_AddNotifications.cs @@ -0,0 +1,176 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace ROLAC.API.Migrations +{ + /// + public partial class AddNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LineBindingCodes", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Code = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + MemberId = table.Column(type: "integer", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + MemberId = table.Column(type: "integer", nullable: false), + Channel = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ExternalId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + BoundAt = table.Column(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(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Channel = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ExternalId = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + IsActive = table.Column(type: "boolean", nullable: false), + RegisteredAt = table.Column(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(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Channel = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TargetType = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + TargetExternalId = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Subject = table.Column(type: "character varying(300)", maxLength: 300, nullable: true), + MemberId = table.Column(type: "integer", nullable: true), + MessagingGroupId = table.Column(type: "integer", nullable: true), + Body = table.Column(type: "text", nullable: false), + Status = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + Error = table.Column(type: "text", nullable: true), + SentByUserId = table.Column(type: "character varying(450)", maxLength: 450, nullable: false), + SentAt = table.Column(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"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LineBindingCodes"); + + migrationBuilder.DropTable( + name: "MemberChannelBindings"); + + migrationBuilder.DropTable( + name: "NotificationLogs"); + + migrationBuilder.DropTable( + name: "MessagingGroups"); + } + } +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index 4db086e..bd00e1f 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -1323,6 +1323,174 @@ namespace ROLAC.API.Migrations b.ToTable("MonthlyStatements"); }); + modelBuilder.Entity("ROLAC.API.Entities.Notifications.LineBindingCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BoundAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("Channel") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("MessagingGroupId") + .HasColumnType("integer"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SentByUserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Subject") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TargetExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("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") diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 8a1b651..7193b10 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -160,6 +160,18 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +// ── Notifications (email via SMTP + Line) ────────────────────────────────── +builder.Services.Configure(config.GetSection("Smtp")); +builder.Services.Configure(config.GetSection("Line")); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHttpClient(); + // --------------------------------------------------------------------------- // Configurable role-based permissions (RBAC matrix) // --------------------------------------------------------------------------- diff --git a/API/ROLAC.API/ROLAC.API.csproj b/API/ROLAC.API/ROLAC.API.csproj index 239c488..39e6945 100644 --- a/API/ROLAC.API/ROLAC.API.csproj +++ b/API/ROLAC.API/ROLAC.API.csproj @@ -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+). --> + diff --git a/API/ROLAC.API/Services/Notifications/EmailService.cs b/API/ROLAC.API/Services/Notifications/EmailService.cs new file mode 100644 index 0000000..720d7af --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/EmailService.cs @@ -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; + +/// +/// 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. +/// +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 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(); + var failures = new List(); + 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> ResolveRecipientsAsync( + EmailMessage message, CancellationToken ct) + { + var resolved = new List<(string Address, int? MemberId)>(); + var seen = new HashSet(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; + } + +} diff --git a/API/ROLAC.API/Services/Notifications/IEmailService.cs b/API/ROLAC.API/Services/Notifications/IEmailService.cs new file mode 100644 index 0000000..88ef311 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/IEmailService.cs @@ -0,0 +1,6 @@ +namespace ROLAC.API.Services.Notifications; + +public interface IEmailService +{ + Task SendAsync(EmailMessage message, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/ILineNotificationService.cs b/API/ROLAC.API/Services/Notifications/ILineNotificationService.cs new file mode 100644 index 0000000..14530df --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/ILineNotificationService.cs @@ -0,0 +1,20 @@ +namespace ROLAC.API.Services.Notifications; + +/// Outcome of a webhook-driven binding attempt. +public sealed record LineBindingResult(bool Success, string Message, int? MemberId); + +/// +/// Line-specific notification operations: outbound push to bound members/groups, plus the +/// webhook-driven binding-code generation/consumption and group registration. +/// +public interface ILineNotificationService +{ + Task SendLineAsync(string body, int[] memberIds, int[] groupIds, + string sentByUserId, CancellationToken ct = default); + + Task GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default); + + Task TryBindMemberAsync(string externalId, string code, CancellationToken ct = default); + Task RegisterGroupAsync(string externalId, CancellationToken ct = default); + Task DeactivateGroupAsync(string externalId, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/IMessageChannel.cs b/API/ROLAC.API/Services/Notifications/IMessageChannel.cs new file mode 100644 index 0000000..5d9d0f1 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/IMessageChannel.cs @@ -0,0 +1,12 @@ +namespace ROLAC.API.Services.Notifications; + +/// Result of one Line REST call. +public sealed record MessageSendResult(bool Success, string? Error); + +/// Abstraction over a chat channel's send/reply (Line today; future channels later). +public interface IMessageChannel +{ + Task PushToUserAsync(string externalId, string text, CancellationToken ct = default); + Task PushToGroupAsync(string externalId, string text, CancellationToken ct = default); + Task ReplyAsync(string replyToken, string text, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs b/API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs new file mode 100644 index 0000000..1cab005 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs @@ -0,0 +1,14 @@ +namespace ROLAC.API.Services.Notifications; + +/// One outbound email envelope handed to the SMTP transport. +public sealed record OutboundEmail( + string ToAddress, + string Subject, + string HtmlBody, + IReadOnlyList Attachments); + +/// Thin seam over the actual MailKit send so EmailService stays unit-testable. +public interface ISmtpDispatcher +{ + Task SendAsync(OutboundEmail email, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs b/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs new file mode 100644 index 0000000..14692d4 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/LineMessageChannel.cs @@ -0,0 +1,52 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Microsoft.Extensions.Options; + +namespace ROLAC.API.Services.Notifications; + +/// Sends text messages and replies via the Line Messaging API REST endpoints. +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 options) + { + _http = http; + _options = options.Value; + } + + public Task PushToUserAsync(string externalId, string text, CancellationToken ct = default) + => PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct); + + public Task PushToGroupAsync(string externalId, string text, CancellationToken ct = default) + => PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct); + + public Task ReplyAsync(string replyToken, string text, CancellationToken ct = default) + => PostAsync(ReplyUrl, new { replyToken, messages = new[] { new { type = "text", text } } }, ct); + + private async Task 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); + } + } +} diff --git a/API/ROLAC.API/Services/Notifications/LineNotificationService.cs b/API/ROLAC.API/Services/Notifications/LineNotificationService.cs new file mode 100644 index 0000000..5a2f786 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/LineNotificationService.cs @@ -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; + +/// +/// Line outbound push + webhook-driven binding/group operations. All sends write a +/// NotificationLog row; binding consumes a short-lived, single-use code. +/// +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 SendLineAsync(string body, int[] memberIds, int[] groupIds, + string sentByUserId, CancellationToken ct = default) + { + var failures = new List(); + 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 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 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); + } +} diff --git a/API/ROLAC.API/Services/Notifications/LineSignature.cs b/API/ROLAC.API/Services/Notifications/LineSignature.cs new file mode 100644 index 0000000..5b8130b --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/LineSignature.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ROLAC.API.Services.Notifications; + +/// Verifies the X-Line-Signature header (HMAC-SHA256 of the raw body, base64). +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)); + } +} diff --git a/API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs b/API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs new file mode 100644 index 0000000..b09e22c --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs @@ -0,0 +1,38 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; + +namespace ROLAC.API.Services.Notifications; + +/// Sends a single email via MailKit using the configured SMTP server. +public sealed class MailKitSmtpDispatcher : ISmtpDispatcher +{ + private readonly SmtpOptions _options; + + public MailKitSmtpDispatcher(IOptions 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); + } +} diff --git a/API/ROLAC.API/Services/Notifications/NotificationModels.cs b/API/ROLAC.API/Services/Notifications/NotificationModels.cs new file mode 100644 index 0000000..d9c464f --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/NotificationModels.cs @@ -0,0 +1,59 @@ +namespace ROLAC.API.Services.Notifications; + +/// Canonical channel discriminators stored in NotificationLog.Channel. +public static class NotificationChannels +{ + public const string Email = "email"; + public const string Line = "line"; +} + +/// Canonical target-type discriminators stored in NotificationLog.TargetType. +public static class NotificationTargetTypes +{ + public const string Email = "email"; + public const string User = "user"; + public const string Group = "group"; +} + +/// Canonical send statuses stored in NotificationLog.Status. +public static class NotificationStatuses +{ + public const string Sent = "sent"; + public const string Failed = "failed"; +} + +/// One failed delivery within a send batch. +public sealed record NotificationFailure(string Target, string Error); + +/// Aggregated outcome of a send call. +public sealed record NotificationResult( + int SentCount, int FailedCount, IReadOnlyList Failures) +{ + public static NotificationResult Empty { get; } = + new(0, 0, Array.Empty()); +} + +/// A file attached to an outbound email. +public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content); + +/// +/// 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. +/// +public sealed record EmailMessage( + IReadOnlyList MemberIds, + IReadOnlyList Addresses, + string Subject, + string HtmlBody, + IReadOnlyList? Attachments = null, + string? SentByUserId = null); + +/// Helpers for building NotificationLog rows consistently across channels. +public static class NotificationLogText +{ + public const int BodyMaxLength = 8000; + + /// Caps a body string so an oversized message can't bloat the log table. + public static string Truncate(string body) => + body.Length <= BodyMaxLength ? body : body[..BodyMaxLength] + "…[truncated]"; +} diff --git a/API/ROLAC.API/Services/Notifications/NotificationOptions.cs b/API/ROLAC.API/Services/Notifications/NotificationOptions.cs new file mode 100644 index 0000000..ab6e8f0 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/NotificationOptions.cs @@ -0,0 +1,20 @@ +namespace ROLAC.API.Services.Notifications; + +/// SMTP transport settings (bound from the "Smtp" config section). +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; } = ""; +} + +/// Line Messaging API settings (bound from the "Line" config section). +public sealed class LineOptions +{ + public string ChannelAccessToken { get; set; } = ""; + public string ChannelSecret { get; set; } = ""; +} diff --git a/API/ROLAC.API/appsettings.json b/API/ROLAC.API/appsettings.json index 4a36c7b..5412db7 100644 --- a/API/ROLAC.API/appsettings.json +++ b/API/ROLAC.API/appsettings.json @@ -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": "" } } diff --git a/docs/NOTIFICATIONS.md b/docs/NOTIFICATIONS.md index 8261730..207056e 100644 --- a/docs/NOTIFICATIONS.md +++ b/docs/NOTIFICATIONS.md @@ -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) **觸發時機** diff --git a/docs/superpowers/plans/2026-06-23-change-password.md b/docs/superpowers/plans/2026-06-23-change-password.md new file mode 100644 index 0000000..8884891 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-change-password.md @@ -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 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> BuildUserManager( + AppUser? findResult = null, + bool passwordOk = true, + IList? 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(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny()), 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 + /// + /// Changes the password for an already-authenticated user. Verifies the current + /// password and enforces the configured Identity password policy via + /// UserManager.ChangePasswordAsync. On success, revokes the user's other + /// active refresh tokens (keeping the one matching ) + /// and writes a security audit entry. Returns the so the + /// caller can surface failures; never throws on a bad password. + /// + Task 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 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 + // ------------------------------------------------------------------------- + + /// + /// 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. + /// + [HttpPost("change-password")] + [Authorize] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task 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 { + return this.http.post( + `${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; + let component: ChangePasswordFormComponent; + let authSpy: jasmine.SpyObj; + let toastSpy: jasmine.SpyObj; + + beforeEach(async () => { + authSpy = jasmine.createSpyObj('AuthService', ['changePassword']); + toastSpy = jasmine.createSpyObj('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 +
+
+ + + + + + Required. + + + + + + + + Required. + + + Must be at least 8 characters with an uppercase letter, a lowercase letter, + a digit, and a special character. + + + + + + + + Required. + + + Passwords do not match. + + + New password must be different from the current password. + + + +
+ +
+ +
+
+``` + +- [ ] **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 +
+
+

Change Password

+

+ Changing your password signs you out on your other devices. +

+ +
+
+``` + +- [ ] **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. diff --git a/docs/superpowers/specs/2026-06-23-change-password-design.md b/docs/superpowers/specs/2026-06-23-change-password-design.md new file mode 100644 index 0000000..f9fb798 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-change-password-design.md @@ -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`, 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` 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.