From 583408032dbbbcd672c9d2a628330d58c138f7de Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Tue, 23 Jun 2026 18:56:04 -0700 Subject: [PATCH] Add implementation plan for Email + Line notification service 12 TDD tasks: MailKit package, entities + migration, email service (SMTP seam), Line message channel + signature verify, Line notification service (send/binding/ groups), webhook + admin controllers, DI + config. Co-Authored-By: Claude Opus 4.8 --- ...6-06-23-notification-service-email-line.md | 1817 +++++++++++++++++ 1 file changed, 1817 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-23-notification-service-email-line.md diff --git a/docs/superpowers/plans/2026-06-23-notification-service-email-line.md b/docs/superpowers/plans/2026-06-23-notification-service-email-line.md new file mode 100644 index 0000000..cf139d2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-notification-service-email-line.md @@ -0,0 +1,1817 @@ +# Notification Service (Email + Line) 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:** Build an API-only notification capability so backend code can send emails (SMTP) and Line messages (push to bound members/groups), with a Line webhook for binding, all audited in one `NotificationLog` table. + +**Architecture:** Two peer services — `IEmailService` (MailKit/SMTP) and `ILineNotificationService` (Line push + webhook-driven binding/groups) — share a single `NotificationLog`. Email sends sit behind an `ISmtpDispatcher` seam so service logic is unit-testable without a real SMTP server; Line REST sits behind `IMessageChannel` so it is testable with a mock `HttpMessageHandler`. An anonymous, HMAC-verified `LineWebhookController` drives binding/group registration; an admin `NotificationsController` exposes binding-code generation, group management, history, and manual send (the only way to fire sends before a UI exists). + +**Tech Stack:** .NET 8, EF Core 8 (Npgsql, in-memory for tests), MailKit/MimeKit, xUnit + Moq. Build/test with `-c Release` and EF tooling with `--configuration Release` (Visual Studio locks `bin/Debug`). + +**Conventions to follow:** +- New code lives under `API/ROLAC.API/`. Tests under `API/ROLAC.API.Tests/`. +- Namespaces: services/records → `ROLAC.API.Services.Notifications`; entities → `ROLAC.API.Entities.Notifications`; controllers → `ROLAC.API.Controllers`. +- Acting user id uses `CurrentUserAccessor.UserIdOrSystem` (handles the `sub`-claim quirk + `"system"` fallback). +- Run all build/test commands from repo root `E:\VSProject\ROLAC`. + +--- + +## File Structure + +**Create:** +- `API/ROLAC.API/Services/Notifications/NotificationOptions.cs` — `SmtpOptions`, `LineOptions`. +- `API/ROLAC.API/Services/Notifications/NotificationModels.cs` — shared records + constant classes. +- `API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs` — SMTP seam + `OutboundEmail`. +- `API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs` — MailKit implementation. +- `API/ROLAC.API/Services/Notifications/IEmailService.cs` / `EmailService.cs`. +- `API/ROLAC.API/Services/Notifications/IMessageChannel.cs` — Line channel abstraction + `MessageSendResult`. +- `API/ROLAC.API/Services/Notifications/LineMessageChannel.cs` — Line REST client. +- `API/ROLAC.API/Services/Notifications/LineSignature.cs` — HMAC-SHA256 verify helper. +- `API/ROLAC.API/Services/Notifications/ILineNotificationService.cs` / `LineNotificationService.cs`. +- `API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs`, `LineBindingCode.cs`, `MessagingGroup.cs`, `NotificationLog.cs`. +- `API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs` — webhook payload DTOs. +- `API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs` — admin controller request DTOs. +- `API/ROLAC.API/Controllers/LineWebhookController.cs`, `NotificationsController.cs`. +- Tests: `LineSignatureTests.cs`, `EmailServiceTests.cs`, `LineNotificationServiceTests.cs`, `LineMessageChannelTests.cs` under `API/ROLAC.API.Tests/Services/Notifications/`. + +**Modify:** +- `API/ROLAC.API/ROLAC.API.csproj` — add MailKit package. +- `API/ROLAC.API/Data/AppDbContext.cs` — DbSets + `OnModelCreating` config. +- `API/ROLAC.API/Program.cs` — DI registrations. +- `API/ROLAC.API/appsettings.json` — `Smtp` + `Line` placeholder sections. + +--- + +## Task 1: Add MailKit package + config option classes + +**Files:** +- Modify: `API/ROLAC.API/ROLAC.API.csproj` +- Create: `API/ROLAC.API/Services/Notifications/NotificationOptions.cs` + +- [ ] **Step 1: Add the MailKit package reference** + +In `API/ROLAC.API/ROLAC.API.csproj`, add inside the existing `` (alphabetical-ish, near the other Microsoft refs): + +```xml + +``` + +- [ ] **Step 2: Restore to confirm the package resolves** + +Run: `dotnet restore API/ROLAC.API/ROLAC.API.csproj` +Expected: Restore succeeds, MailKit + MimeKit downloaded. + +- [ ] **Step 3: Create the options classes** + +Create `API/ROLAC.API/Services/Notifications/NotificationOptions.cs`: + +```csharp +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; } = ""; +} +``` + +- [ ] **Step 4: Build to confirm it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/ROLAC.API.csproj API/ROLAC.API/Services/Notifications/NotificationOptions.cs +git commit -m "Add MailKit package and notification option classes" +``` + +--- + +## Task 2: Shared models, records, and constants + +**Files:** +- Create: `API/ROLAC.API/Services/Notifications/NotificationModels.cs` + +- [ ] **Step 1: Create the shared models file** + +Create `API/ROLAC.API/Services/Notifications/NotificationModels.cs`: + +```csharp +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); +``` + +- [ ] **Step 2: Build to confirm it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 3: Commit** + +```bash +git add API/ROLAC.API/Services/Notifications/NotificationModels.cs +git commit -m "Add shared notification models, records, and constants" +``` + +--- + +## Task 3: Entities + DbContext wiring + migration + +**Files:** +- Create: `API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs`, `LineBindingCode.cs`, `MessagingGroup.cs`, `NotificationLog.cs` +- Modify: `API/ROLAC.API/Data/AppDbContext.cs` + +- [ ] **Step 1: Create `MemberChannelBinding`** + +Create `API/ROLAC.API/Entities/Notifications/MemberChannelBinding.cs`: + +```csharp +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; } +} +``` + +- [ ] **Step 2: Create `LineBindingCode`** + +Create `API/ROLAC.API/Entities/Notifications/LineBindingCode.cs`: + +```csharp +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 +} +``` + +- [ ] **Step 3: Create `MessagingGroup`** + +Create `API/ROLAC.API/Entities/Notifications/MessagingGroup.cs`: + +```csharp +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; } +} +``` + +- [ ] **Step 4: Create `NotificationLog`** + +Create `API/ROLAC.API/Entities/Notifications/NotificationLog.cs`: + +```csharp +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; } +} +``` + +- [ ] **Step 5: Add DbSets to `AppDbContext`** + +In `API/ROLAC.API/Data/AppDbContext.cs`, add the using and DbSets. After the existing `using ROLAC.API.Entities;` line add: + +```csharp +using ROLAC.API.Entities.Notifications; +``` + +After the `public DbSet RolePermissions => Set();` line, add: + +```csharp + public DbSet MemberChannelBindings => Set(); + public DbSet LineBindingCodes => Set(); + public DbSet MessagingGroups => Set(); + public DbSet NotificationLogs => Set(); +``` + +- [ ] **Step 6: Add entity configuration in `OnModelCreating`** + +In `API/ROLAC.API/Data/AppDbContext.cs`, immediately before the `LogModelConfiguration.Configure(builder);` line at the end of `OnModelCreating`, add: + +```csharp + // ── 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); + }); +``` + +- [ ] **Step 7: Build to confirm the model compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 8: Create the EF migration** + +Run: `dotnet ef migrations add AddNotifications --project API/ROLAC.API/ROLAC.API.csproj --configuration Release` +Expected: A new migration appears under `API/ROLAC.API/Migrations/` creating the four tables. (The app applies it on startup via `MigrateAsync`; do not run `database update` manually.) + +- [ ] **Step 9: Commit** + +```bash +git add API/ROLAC.API/Entities/Notifications API/ROLAC.API/Data/AppDbContext.cs API/ROLAC.API/Migrations +git commit -m "Add notification entities, DbContext config, and migration" +``` + +--- + +## Task 4: `LineSignature` HMAC helper (TDD) + +**Files:** +- Create: `API/ROLAC.API/Services/Notifications/LineSignature.cs` +- Test: `API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs`: + +```csharp +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, "")); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineSignatureTests"` +Expected: FAIL — `LineSignature` does not exist (compile error). + +- [ ] **Step 3: Implement `LineSignature`** + +Create `API/ROLAC.API/Services/Notifications/LineSignature.cs`: + +```csharp +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)); + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineSignatureTests"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/Services/Notifications/LineSignature.cs API/ROLAC.API.Tests/Services/Notifications/LineSignatureTests.cs +git commit -m "Add Line webhook signature verification helper" +``` + +--- + +## Task 5: SMTP dispatcher seam + MailKit implementation + +**Files:** +- Create: `API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs`, `MailKitSmtpDispatcher.cs` + +> No unit test for `MailKitSmtpDispatcher` — it is the thin transport boundary (needs a real SMTP server). `EmailService` (Task 6) is tested against a fake `ISmtpDispatcher`. + +- [ ] **Step 1: Create the dispatcher seam** + +Create `API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs`: + +```csharp +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); +} +``` + +- [ ] **Step 2: Implement `MailKitSmtpDispatcher`** + +Create `API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs`: + +```csharp +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); + } +} +``` + +- [ ] **Step 3: Build to confirm it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/Services/Notifications/ISmtpDispatcher.cs API/ROLAC.API/Services/Notifications/MailKitSmtpDispatcher.cs +git commit -m "Add SMTP dispatcher seam and MailKit implementation" +``` + +--- + +## Task 6: `EmailService` (TDD) + +**Files:** +- Create: `API/ROLAC.API/Services/Notifications/IEmailService.cs`, `EmailService.cs` +- Test: `API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs` + +- [ ] **Step 1: Create the interface** + +Create `API/ROLAC.API/Services/Notifications/IEmailService.cs`: + +```csharp +namespace ROLAC.API.Services.Notifications; + +public interface IEmailService +{ + Task SendAsync(EmailMessage message, CancellationToken ct = default); +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs`: + +```csharp +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)); + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~EmailServiceTests"` +Expected: FAIL — `EmailService` does not exist (compile error). + +- [ ] **Step 4: Implement `EmailService`** + +Create `API/ROLAC.API/Services/Notifications/EmailService.cs`: + +```csharp +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 const int BodyLogMaxLength = 8000; + + 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 = 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(m => message.MemberIds.Contains(m.Id) && m.Email != null && m.Email != "") + .Select(m => new { m.Id, m.Email }) + .ToListAsync(ct); + foreach (var m in members) + if (seen.Add(m.Email!)) + resolved.Add((m.Email!, m.Id)); + } + + foreach (var address in message.Addresses) + { + var trimmed = address?.Trim(); + if (!string.IsNullOrWhiteSpace(trimmed) && seen.Add(trimmed)) + resolved.Add((trimmed, null)); + } + + return resolved; + } + + private static string Truncate(string body) => + body.Length <= BodyLogMaxLength ? body : body[..BodyLogMaxLength] + "…[truncated]"; +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~EmailServiceTests"` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Services/Notifications/IEmailService.cs API/ROLAC.API/Services/Notifications/EmailService.cs API/ROLAC.API.Tests/Services/Notifications/EmailServiceTests.cs +git commit -m "Add EmailService with recipient resolution and logging" +``` + +--- + +## Task 7: `IMessageChannel` + `LineMessageChannel` (TDD) + +**Files:** +- Create: `API/ROLAC.API/Services/Notifications/IMessageChannel.cs`, `LineMessageChannel.cs` +- Test: `API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs` + +- [ ] **Step 1: Create the interface** + +Create `API/ROLAC.API/Services/Notifications/IMessageChannel.cs`: + +```csharp +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); +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs`: + +```csharp +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); + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineMessageChannelTests"` +Expected: FAIL — `LineMessageChannel` does not exist (compile error). + +- [ ] **Step 4: Implement `LineMessageChannel`** + +Create `API/ROLAC.API/Services/Notifications/LineMessageChannel.cs`: + +```csharp +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); + } + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineMessageChannelTests"` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Services/Notifications/IMessageChannel.cs API/ROLAC.API/Services/Notifications/LineMessageChannel.cs API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs +git commit -m "Add IMessageChannel and Line REST implementation" +``` + +--- + +## Task 8: `LineNotificationService` (TDD) + +**Files:** +- Create: `API/ROLAC.API/Services/Notifications/ILineNotificationService.cs`, `LineNotificationService.cs` +- Test: `API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs` + +- [ ] **Step 1: Create the interface** + +Create `API/ROLAC.API/Services/Notifications/ILineNotificationService.cs`: + +```csharp +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); +} +``` + +- [ ] **Step 2: Write the failing test** + +Create `API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs`: + +```csharp +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)); + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineNotificationServiceTests"` +Expected: FAIL — `LineNotificationService` does not exist (compile error). + +- [ ] **Step 4: Implement `LineNotificationService`** + +Create `API/ROLAC.API/Services/Notifications/LineNotificationService.cs`: + +```csharp +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 bindings = await _db.MemberChannelBindings + .Where(b => b.Channel == Channel && memberIds.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 = 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); + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release --filter "FullyQualifiedName~LineNotificationServiceTests"` +Expected: PASS (7 tests). + +- [ ] **Step 6: Commit** + +```bash +git add API/ROLAC.API/Services/Notifications/ILineNotificationService.cs API/ROLAC.API/Services/Notifications/LineNotificationService.cs API/ROLAC.API.Tests/Services/Notifications/LineNotificationServiceTests.cs +git commit -m "Add LineNotificationService with send, binding, and group ops" +``` + +--- + +## Task 9: Webhook DTOs + `LineWebhookController` + +**Files:** +- Create: `API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs`, `API/ROLAC.API/Controllers/LineWebhookController.cs` + +> No unit test for the controller — its DB/binding logic lives in `LineNotificationService` (already tested in Task 8) and signature verification in `LineSignature` (Task 4). The controller is thin wiring. + +- [ ] **Step 1: Create the webhook DTOs** + +Create `API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs`: + +```csharp +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; } +} +``` + +- [ ] **Step 2: Implement `LineWebhookController`** + +Create `API/ROLAC.API/Controllers/LineWebhookController.cs`: + +```csharp +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")] + 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; + } + } +} +``` + +- [ ] **Step 3: Build to confirm it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/DTOs/Notifications/LineWebhookDtos.cs API/ROLAC.API/Controllers/LineWebhookController.cs +git commit -m "Add Line webhook controller with signature verification and dispatch" +``` + +--- + +## Task 10: Admin request DTOs + `NotificationsController` + +**Files:** +- Create: `API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs`, `API/ROLAC.API/Controllers/NotificationsController.cs` + +- [ ] **Step 1: Create the request DTOs** + +Create `API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs`: + +```csharp +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); +``` + +- [ ] **Step 2: Implement `NotificationsController`** + +Create `API/ROLAC.API/Controllers/NotificationsController.cs`: + +```csharp +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)); +} +``` + +- [ ] **Step 3: Build to confirm it compiles** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs API/ROLAC.API/Controllers/NotificationsController.cs +git commit -m "Add admin NotificationsController for binding, groups, history, and send" +``` + +--- + +## Task 11: DI registration + config + full verification + +**Files:** +- Modify: `API/ROLAC.API/Program.cs`, `API/ROLAC.API/appsettings.json` + +- [ ] **Step 1: Register services in `Program.cs`** + +In `API/ROLAC.API/Program.cs`, in the "Application services" region (after the `builder.Services.AddScoped();` line), add: + +```csharp + +// ── 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(); +``` + +- [ ] **Step 2: Add config sections to `appsettings.json`** + +In `API/ROLAC.API/appsettings.json`, add these two top-level sections (placeholders only — real secrets go in user-secrets / `appsettings.Development.json` / environment variables, never committed): + +```jsonc + "Smtp": { + "Host": "", + "Port": 587, + "UseSsl": true, + "User": "", + "Password": "", + "FromAddress": "noreply@rolac.org", + "FromName": "River of Life Christian Church" + }, + "Line": { + "ChannelAccessToken": "", + "ChannelSecret": "" + } +``` + +- [ ] **Step 3: Build the full API project** + +Run: `dotnet build API/ROLAC.API/ROLAC.API.csproj -c Release` +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Run the entire test suite** + +Run: `dotnet test API/ROLAC.API.Tests/ROLAC.API.Tests.csproj -c Release` +Expected: PASS — all existing tests plus the new notification tests (LineSignature 3, EmailService 3, LineMessageChannel 3, LineNotificationService 7). + +- [ ] **Step 5: Commit** + +```bash +git add API/ROLAC.API/Program.cs API/ROLAC.API/appsettings.json +git commit -m "Register notification services and add SMTP/Line config sections" +``` + +--- + +## Task 12: Final review & docs touch-up + +**Files:** +- Modify: `docs/NOTIFICATIONS.md` (optional cross-link) + +- [ ] **Step 1: Confirm no secrets were committed** + +Run: `git log --oneline -12` and `git grep -nI "ChannelAccessToken\|Password" -- API/ROLAC.API/appsettings.json` +Expected: `appsettings.json` shows only empty-string placeholders; no real tokens/passwords anywhere in the diff. + +- [ ] **Step 2: Add a pointer in `docs/NOTIFICATIONS.md`** + +At the top of `API/ROLAC.API` notifications context, append a line under the Email section of `docs/NOTIFICATIONS.md` noting the implemented design: + +```markdown +> **實作狀態 (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)。 +``` + +- [ ] **Step 3: Commit** + +```bash +git add docs/NOTIFICATIONS.md +git commit -m "Cross-link implemented notification design in NOTIFICATIONS.md" +``` + +--- + +## Operator Setup (non-code, before this works in production) + +These are environment steps the engineer cannot do in code — flag them to the church admin: + +1. **SMTP:** Obtain mailbox host / port / user / password / from-address (M365, Google Workspace, or host SMTP). Set them in user-secrets or `appsettings.Development.json` (dev) and environment variables (prod). Configure SPF/DKIM on the sending domain for deliverability. +2. **Line:** Create a Line Official Account + Messaging API channel; copy **Channel access token** and **Channel secret** into the `Line` config (secrets store, not committed). +3. **nginx:** Route `https:///api/line/webhook` to the API; enter that URL as the webhook in the Line console and enable webhooks. + +--- + +## Self-Review Notes (completed by plan author) + +- **Spec coverage:** Email service (§4.1) → Tasks 5–6; Line service + binding/groups (§4.2, §4.5) → Tasks 7–9; `IMessageChannel`/Line REST (§4.4) → Task 7; signature verify (§4.5) → Task 4; admin endpoints (§4.6) → Task 10; data model (§3) → Task 3; config/DI (§5) → Task 11; error handling (§6) → covered in service implementations + webhook; tests (§7) → Tasks 4, 6, 7, 8; out-of-scope items (§10) deliberately omitted. +- **Type consistency:** `NotificationResult`, `NotificationFailure`, `EmailMessage`, `MessageSendResult`, `LineBindingResult`, and the constant classes are defined once (Tasks 2, 7, 8) and referenced consistently. Channel/status/target-type string literals always go through the `NotificationChannels`/`NotificationStatuses`/`NotificationTargetTypes` constants. +- **No placeholders:** every code step contains complete, compilable code.