# 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.