Files
ROLAC/docs/superpowers/plans/2026-06-23-notification-service-email-line.md
T
Chris Chen 583408032d Add implementation plan for Email + Line notification service
12 TDD tasks: MailKit package, entities + migration, email service (SMTP seam),
Line message channel + signature verify, Line notification service (send/binding/
groups), webhook + admin controllers, DI + config.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:56:04 -07:00

1818 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `<ItemGroup>` (alphabetical-ish, near the other Microsoft refs):
```xml
<PackageReference Include="MailKit" Version="4.8.0" />
```
- [ ] **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;
/// <summary>SMTP transport settings (bound from the "Smtp" config section).</summary>
public sealed class SmtpOptions
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public bool UseSsl { get; set; } = true; // true → STARTTLS
public string User { get; set; } = "";
public string Password { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromName { get; set; } = "";
}
/// <summary>Line Messaging API settings (bound from the "Line" config section).</summary>
public sealed class LineOptions
{
public string ChannelAccessToken { get; set; } = "";
public string ChannelSecret { get; set; } = "";
}
```
- [ ] **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;
/// <summary>Canonical channel discriminators stored in NotificationLog.Channel.</summary>
public static class NotificationChannels
{
public const string Email = "email";
public const string Line = "line";
}
/// <summary>Canonical target-type discriminators stored in NotificationLog.TargetType.</summary>
public static class NotificationTargetTypes
{
public const string Email = "email";
public const string User = "user";
public const string Group = "group";
}
/// <summary>Canonical send statuses stored in NotificationLog.Status.</summary>
public static class NotificationStatuses
{
public const string Sent = "sent";
public const string Failed = "failed";
}
/// <summary>One failed delivery within a send batch.</summary>
public sealed record NotificationFailure(string Target, string Error);
/// <summary>Aggregated outcome of a send call.</summary>
public sealed record NotificationResult(
int SentCount, int FailedCount, IReadOnlyList<NotificationFailure> Failures)
{
public static NotificationResult Empty { get; } =
new(0, 0, Array.Empty<NotificationFailure>());
}
/// <summary>A file attached to an outbound email.</summary>
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
/// <summary>
/// A request to send one email to a set of members (resolved via Member.Email) and/or raw
/// addresses. The caller supplies the final HTML body — no templating in this phase.
/// </summary>
public sealed record EmailMessage(
IReadOnlyList<int> MemberIds,
IReadOnlyList<string> Addresses,
string Subject,
string HtmlBody,
IReadOnlyList<EmailAttachment>? Attachments = null,
string? SentByUserId = null);
```
- [ ] **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;
/// <summary>
/// Binds a member to an external channel account (e.g. a Line userId). Separate table so future
/// channels don't require changes to Member.
/// </summary>
public class MemberChannelBinding
{
public int Id { get; set; }
public int MemberId { get; set; }
public Member? Member { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line userId
public DateTime BoundAt { get; set; }
}
```
- [ ] **Step 2: Create `LineBindingCode`**
Create `API/ROLAC.API/Entities/Notifications/LineBindingCode.cs`:
```csharp
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>A short-lived code a member types to the Line bot to complete account binding.</summary>
public class LineBindingCode
{
public int Id { get; set; }
public string Code { get; set; } = null!;
public int MemberId { get; set; }
public Member? Member { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime? ConsumedAt { get; set; } // null = unused
}
```
- [ ] **Step 3: Create `MessagingGroup`**
Create `API/ROLAC.API/Entities/Notifications/MessagingGroup.cs`:
```csharp
namespace ROLAC.API.Entities.Notifications;
/// <summary>A Line group the bot was added to. Named by an admin after the join event.</summary>
public class MessagingGroup
{
public int Id { get; set; }
public string Channel { get; set; } = null!; // "line"
public string ExternalId { get; set; } = null!; // Line groupId
public string? Name { get; set; }
public bool IsActive { get; set; } = true;
public DateTime RegisteredAt { get; set; }
}
```
- [ ] **Step 4: Create `NotificationLog`**
Create `API/ROLAC.API/Entities/Notifications/NotificationLog.cs`:
```csharp
using ROLAC.API.Entities;
namespace ROLAC.API.Entities.Notifications;
/// <summary>An append-only audit row for every email or Line send (success or failure).</summary>
public class NotificationLog
{
public long Id { get; set; }
public string Channel { get; set; } = null!; // "email" | "line"
public string TargetType { get; set; } = null!; // "email" | "user" | "group"
public string TargetExternalId { get; set; } = null!; // email address OR Line id
public string? Subject { get; set; } // email only
public int? MemberId { get; set; }
public Member? Member { get; set; }
public int? MessagingGroupId { get; set; }
public MessagingGroup? MessagingGroup { get; set; }
public string Body { get; set; } = null!;
public string Status { get; set; } = null!; // "sent" | "failed"
public string? Error { get; set; }
public string SentByUserId { get; set; } = null!;
public DateTime SentAt { get; set; }
}
```
- [ ] **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<RolePermission> RolePermissions => Set<RolePermission>();` line, add:
```csharp
public DbSet<MemberChannelBinding> MemberChannelBindings => Set<MemberChannelBinding>();
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
```
- [ ] **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<MemberChannelBinding>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.HasIndex(e => new { e.MemberId, e.Channel }).IsUnique();
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<LineBindingCode>(entity =>
{
entity.Property(e => e.Code).HasMaxLength(20).IsRequired();
entity.HasIndex(e => e.Code);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessagingGroup>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.ExternalId).HasMaxLength(100).IsRequired();
entity.Property(e => e.Name).HasMaxLength(200);
entity.HasIndex(e => new { e.Channel, e.ExternalId }).IsUnique();
});
builder.Entity<NotificationLog>(entity =>
{
entity.Property(e => e.Channel).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetType).HasMaxLength(20).IsRequired();
entity.Property(e => e.TargetExternalId).HasMaxLength(200).IsRequired();
entity.Property(e => e.Subject).HasMaxLength(300);
entity.Property(e => e.Status).HasMaxLength(20).IsRequired();
entity.Property(e => e.SentByUserId).HasMaxLength(450).IsRequired();
entity.HasIndex(e => e.SentAt);
entity.HasIndex(e => e.Channel);
entity.HasOne(e => e.Member).WithMany()
.HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.MessagingGroup).WithMany()
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
});
```
- [ ] **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;
/// <summary>Verifies the X-Line-Signature header (HMAC-SHA256 of the raw body, base64).</summary>
public static class LineSignature
{
public static bool IsValid(string channelSecret, byte[] rawBody, string? signatureHeader)
{
if (string.IsNullOrEmpty(signatureHeader)) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(channelSecret));
var expected = Convert.ToBase64String(hmac.ComputeHash(rawBody));
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(signatureHeader));
}
}
```
- [ ] **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;
/// <summary>One outbound email envelope handed to the SMTP transport.</summary>
public sealed record OutboundEmail(
string ToAddress,
string Subject,
string HtmlBody,
IReadOnlyList<EmailAttachment> Attachments);
/// <summary>Thin seam over the actual MailKit send so EmailService stays unit-testable.</summary>
public interface ISmtpDispatcher
{
Task SendAsync(OutboundEmail email, CancellationToken ct = default);
}
```
- [ ] **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;
/// <summary>Sends a single email via MailKit using the configured SMTP server.</summary>
public sealed class MailKitSmtpDispatcher : ISmtpDispatcher
{
private readonly SmtpOptions _options;
public MailKitSmtpDispatcher(IOptions<SmtpOptions> options) => _options = options.Value;
public async Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
var message = new MimeMessage();
message.From.Add(new MailboxAddress(_options.FromName, _options.FromAddress));
message.To.Add(MailboxAddress.Parse(email.ToAddress));
message.Subject = email.Subject;
var builder = new BodyBuilder { HtmlBody = email.HtmlBody };
foreach (var attachment in email.Attachments)
{
builder.Attachments.Add(
attachment.FileName, attachment.Content, ContentType.Parse(attachment.ContentType));
}
message.Body = builder.ToMessageBody();
using var client = new SmtpClient();
var socketOptions = _options.UseSsl ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(_options.Host, _options.Port, socketOptions, ct);
if (!string.IsNullOrEmpty(_options.User))
await client.AuthenticateAsync(_options.User, _options.Password, ct);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
}
```
- [ ] **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<NotificationResult> 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<OutboundEmail> Sent { get; } = new();
public string? FailForAddress { get; set; }
public Task SendAsync(OutboundEmail email, CancellationToken ct = default)
{
if (email.ToAddress == FailForAddress)
throw new InvalidOperationException("smtp rejected");
Sent.Add(email);
return Task.CompletedTask;
}
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db, string? email)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User", Email = email };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task SendAsync_ResolvesMemberEmails_MergesRawAddresses_AndDedupes()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, "member@example.com");
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var message = new EmailMessage(
MemberIds: new[] { memberId },
Addresses: new[] { "extra@example.com", "member@example.com" }, // dup of member email
Subject: "Hi", HtmlBody: "<p>Body</p>");
var result = await service.SendAsync(message);
Assert.Equal(2, result.SentCount); // member@ + extra@, dup dropped
Assert.Equal(0, result.FailedCount);
Assert.Equal(2, dispatcher.Sent.Count);
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendAsync_SkipsMembersWithNoEmail()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db, null);
var dispatcher = new FakeSmtpDispatcher();
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
new[] { memberId }, Array.Empty<string>(), "Hi", "<p>Body</p>"));
Assert.Equal(0, result.SentCount);
Assert.Empty(dispatcher.Sent);
}
[Fact]
public async Task SendAsync_LogsFailure_WithoutAbortingBatch()
{
using var db = BuildDb();
var dispatcher = new FakeSmtpDispatcher { FailForAddress = "bad@example.com" };
var service = new EmailService(db, dispatcher, BuildAccessor());
var result = await service.SendAsync(new EmailMessage(
Array.Empty<int>(),
new[] { "bad@example.com", "good@example.com" },
"Hi", "<p>Body</p>"));
Assert.Equal(1, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Single(result.Failures);
Assert.Equal("bad@example.com", result.Failures[0].Target);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
}
```
- [ ] **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;
/// <summary>
/// Resolves recipients (member emails + raw addresses, deduped), sends each via the SMTP
/// dispatcher, and writes a NotificationLog row per recipient. A single failure never aborts the
/// batch — it is recorded and reported in the summary.
/// </summary>
public sealed class EmailService : IEmailService
{
private 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<NotificationResult> SendAsync(EmailMessage message, CancellationToken ct = default)
{
var recipients = await ResolveRecipientsAsync(message, ct);
if (recipients.Count == 0) return NotificationResult.Empty;
var sentBy = message.SentByUserId ?? _currentUser.UserIdOrSystem;
var attachments = message.Attachments ?? Array.Empty<EmailAttachment>();
var failures = new List<NotificationFailure>();
var sentCount = 0;
foreach (var recipient in recipients)
{
var log = new NotificationLog
{
Channel = NotificationChannels.Email,
TargetType = NotificationTargetTypes.Email,
TargetExternalId = recipient.Address,
Subject = message.Subject,
MemberId = recipient.MemberId,
Body = Truncate(message.HtmlBody),
SentByUserId = sentBy,
SentAt = DateTime.UtcNow,
};
try
{
await _dispatcher.SendAsync(
new OutboundEmail(recipient.Address, message.Subject, message.HtmlBody, attachments), ct);
log.Status = NotificationStatuses.Sent;
sentCount++;
}
catch (Exception ex)
{
log.Status = NotificationStatuses.Failed;
log.Error = ex.Message;
failures.Add(new NotificationFailure(recipient.Address, ex.Message));
}
_db.NotificationLogs.Add(log);
}
await _db.SaveChangesAsync(ct);
return new NotificationResult(sentCount, failures.Count, failures);
}
private async Task<IReadOnlyList<(string Address, int? MemberId)>> ResolveRecipientsAsync(
EmailMessage message, CancellationToken ct)
{
var resolved = new List<(string Address, int? MemberId)>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (message.MemberIds.Count > 0)
{
var members = await _db.Members
.Where(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;
/// <summary>Result of one Line REST call.</summary>
public sealed record MessageSendResult(bool Success, string? Error);
/// <summary>Abstraction over a chat channel's send/reply (Line today; future channels later).</summary>
public interface IMessageChannel
{
Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default);
Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default);
Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default);
}
```
- [ ] **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<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
LastBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(StatusCode) { Content = new StringContent(ResponseBody) };
}
}
private static LineMessageChannel BuildChannel(CapturingHandler handler)
{
var http = new HttpClient(handler);
var options = Options.Create(new LineOptions { ChannelAccessToken = "tok", ChannelSecret = "sec" });
return new LineMessageChannel(http, options);
}
[Fact]
public async Task PushToUserAsync_PostsTextMessage_WithBearerToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.True(result.Success);
Assert.Equal("https://api.line.me/v2/bot/message/push", handler.LastRequest!.RequestUri!.ToString());
Assert.Equal("Bearer", handler.LastRequest.Headers.Authorization!.Scheme);
Assert.Equal("tok", handler.LastRequest.Headers.Authorization.Parameter);
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("U123", doc.RootElement.GetProperty("to").GetString());
Assert.Equal("hello", doc.RootElement.GetProperty("messages")[0].GetProperty("text").GetString());
}
[Fact]
public async Task ReplyAsync_PostsToReplyEndpoint_WithReplyToken()
{
var handler = new CapturingHandler();
var channel = BuildChannel(handler);
await channel.ReplyAsync("RTOKEN", "hi back");
Assert.Equal("https://api.line.me/v2/bot/message/reply", handler.LastRequest!.RequestUri!.ToString());
using var doc = JsonDocument.Parse(handler.LastBody!);
Assert.Equal("RTOKEN", doc.RootElement.GetProperty("replyToken").GetString());
}
[Fact]
public async Task PushToUserAsync_ReturnsFailure_OnNonSuccessStatus()
{
var handler = new CapturingHandler { StatusCode = HttpStatusCode.TooManyRequests, ResponseBody = "quota" };
var channel = BuildChannel(handler);
var result = await channel.PushToUserAsync("U123", "hello");
Assert.False(result.Success);
Assert.Contains("429", result.Error);
}
}
```
- [ ] **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;
/// <summary>Sends text messages and replies via the Line Messaging API REST endpoints.</summary>
public sealed class LineMessageChannel : IMessageChannel
{
private const string PushUrl = "https://api.line.me/v2/bot/message/push";
private const string ReplyUrl = "https://api.line.me/v2/bot/message/reply";
private readonly HttpClient _http;
private readonly LineOptions _options;
public LineMessageChannel(HttpClient http, IOptions<LineOptions> options)
{
_http = http;
_options = options.Value;
}
public Task<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
=> PostAsync(PushUrl, new { to = externalId, messages = new[] { new { type = "text", text } } }, ct);
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> PostAsync(ReplyUrl, new { replyToken, messages = new[] { new { type = "text", text } } }, ct);
private async Task<MessageSendResult> PostAsync(string url, object payload, CancellationToken ct)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ChannelAccessToken);
using var response = await _http.SendAsync(request, ct);
if (response.IsSuccessStatusCode) return new MessageSendResult(true, null);
var body = await response.Content.ReadAsStringAsync(ct);
return new MessageSendResult(false, $"{(int)response.StatusCode}: {body}");
}
catch (Exception ex)
{
return new MessageSendResult(false, ex.Message);
}
}
}
```
- [ ] **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;
/// <summary>Outcome of a webhook-driven binding attempt.</summary>
public sealed record LineBindingResult(bool Success, string Message, int? MemberId);
/// <summary>
/// Line-specific notification operations: outbound push to bound members/groups, plus the
/// webhook-driven binding-code generation/consumption and group registration.
/// </summary>
public interface ILineNotificationService
{
Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
string sentByUserId, CancellationToken ct = default);
Task<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default);
Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default);
Task RegisterGroupAsync(string externalId, CancellationToken ct = default);
Task DeactivateGroupAsync(string externalId, CancellationToken ct = default);
}
```
- [ ] **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<MessageSendResult> PushToUserAsync(string externalId, string text, CancellationToken ct = default)
{
UserPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> PushToGroupAsync(string externalId, string text, CancellationToken ct = default)
{
GroupPushes.Add((externalId, text));
return Task.FromResult(new MessageSendResult(!Fail, Fail ? "boom" : null));
}
public Task<MessageSendResult> ReplyAsync(string replyToken, string text, CancellationToken ct = default)
=> Task.FromResult(new MessageSendResult(true, null));
}
private static CurrentUserAccessor BuildAccessor(string userId = "test-user")
{
var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) };
var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) };
var mock = new Mock<IHttpContextAccessor>();
mock.Setup(x => x.HttpContext).Returns(ctx);
return new CurrentUserAccessor(mock.Object);
}
private static AppDbContext BuildDb()
{
var interceptor = new AuditSaveChangesInterceptor(BuildAccessor());
return new AppDbContext(
new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.AddInterceptors(interceptor)
.Options);
}
private static async Task<int> SeedMemberAsync(AppDbContext db)
{
var member = new Member { FirstName_en = "Test", LastName_en = "User" };
db.Members.Add(member);
await db.SaveChangesAsync();
return member.Id;
}
[Fact]
public async Task GenerateLineBindingCodeAsync_PersistsUnconsumedCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var stored = await db.LineBindingCodes.SingleAsync();
Assert.Equal(code, stored.Code);
Assert.Null(stored.ConsumedAt);
Assert.True(stored.ExpiresAt > DateTime.UtcNow);
}
[Fact]
public async Task TryBindMemberAsync_BindsMember_AndConsumesCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
var code = await service.GenerateLineBindingCodeAsync(memberId);
var result = await service.TryBindMemberAsync("U999", code);
Assert.True(result.Success);
Assert.Equal(memberId, result.MemberId);
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U999", binding.ExternalId);
Assert.NotNull((await db.LineBindingCodes.SingleAsync()).ConsumedAt);
}
[Fact]
public async Task TryBindMemberAsync_Fails_ForExpiredOrUsedOrUnknownCode()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.LineBindingCodes.Add(new LineBindingCode
{
Code = "EXPIRE", MemberId = memberId, ExpiresAt = DateTime.UtcNow.AddMinutes(-1),
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel());
Assert.False((await service.TryBindMemberAsync("U1", "EXPIRE")).Success); // expired
Assert.False((await service.TryBindMemberAsync("U1", "NOPE")).Success); // unknown
Assert.Empty(await db.MemberChannelBindings.ToListAsync());
}
[Fact]
public async Task TryBindMemberAsync_Rebinds_UpdatesExistingBinding()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.TryBindMemberAsync("U-OLD", await service.GenerateLineBindingCodeAsync(memberId));
await service.TryBindMemberAsync("U-NEW", await service.GenerateLineBindingCodeAsync(memberId));
var binding = await db.MemberChannelBindings.SingleAsync();
Assert.Equal("U-NEW", binding.ExternalId);
}
[Fact]
public async Task RegisterGroupAsync_IsIdempotent_AndDeactivateFlips()
{
using var db = BuildDb();
var service = new LineNotificationService(db, new FakeMessageChannel());
await service.RegisterGroupAsync("G1");
await service.RegisterGroupAsync("G1"); // second call must not duplicate
Assert.Equal(1, await db.MessagingGroups.CountAsync());
Assert.True((await db.MessagingGroups.SingleAsync()).IsActive);
await service.DeactivateGroupAsync("G1");
Assert.False((await db.MessagingGroups.SingleAsync()).IsActive);
}
[Fact]
public async Task SendLineAsync_PushesToBoundMembersAndActiveGroups_AndLogs()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
var activeGroup = new MessagingGroup { Channel = "line", ExternalId = "G-ON", IsActive = true, RegisteredAt = DateTime.UtcNow };
var deadGroup = new MessagingGroup { Channel = "line", ExternalId = "G-OFF", IsActive = false, RegisteredAt = DateTime.UtcNow };
db.MessagingGroups.AddRange(activeGroup, deadGroup);
await db.SaveChangesAsync();
var channel = new FakeMessageChannel();
var service = new LineNotificationService(db, channel);
var result = await service.SendLineAsync("notice", new[] { memberId },
new[] { activeGroup.Id, deadGroup.Id }, "admin-1");
Assert.Equal(2, result.SentCount); // member + active group only
Assert.Single(channel.UserPushes);
Assert.Single(channel.GroupPushes); // inactive group skipped
Assert.Equal(2, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Sent));
}
[Fact]
public async Task SendLineAsync_RecordsFailures_WhenChannelFails()
{
using var db = BuildDb();
var memberId = await SeedMemberAsync(db);
db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = memberId, Channel = "line", ExternalId = "U-MEM", BoundAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
var service = new LineNotificationService(db, new FakeMessageChannel { Fail = true });
var result = await service.SendLineAsync("notice", new[] { memberId }, Array.Empty<int>(), "admin-1");
Assert.Equal(0, result.SentCount);
Assert.Equal(1, result.FailedCount);
Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed));
}
}
```
- [ ] **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;
/// <summary>
/// Line outbound push + webhook-driven binding/group operations. All sends write a
/// NotificationLog row; binding consumes a short-lived, single-use code.
/// </summary>
public sealed class LineNotificationService : ILineNotificationService
{
private const string Channel = NotificationChannels.Line;
private const string CodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no I/O/0/1
private const int CodeLength = 6;
private static readonly TimeSpan CodeLifetime = TimeSpan.FromMinutes(15);
private readonly AppDbContext _db;
private readonly IMessageChannel _channel;
public LineNotificationService(AppDbContext db, IMessageChannel channel)
{
_db = db;
_channel = channel;
}
public async Task<NotificationResult> SendLineAsync(string body, int[] memberIds, int[] groupIds,
string sentByUserId, CancellationToken ct = default)
{
var failures = new List<NotificationFailure>();
var sentCount = 0;
var 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<string> GenerateLineBindingCodeAsync(int memberId, CancellationToken ct = default)
{
var code = GenerateCode();
_db.LineBindingCodes.Add(new LineBindingCode
{
Code = code,
MemberId = memberId,
ExpiresAt = DateTime.UtcNow.Add(CodeLifetime),
});
await _db.SaveChangesAsync(ct);
return code;
}
public async Task<LineBindingResult> TryBindMemberAsync(string externalId, string code, CancellationToken ct = default)
{
var normalized = code.Trim().ToUpperInvariant();
var now = DateTime.UtcNow;
var bindingCode = await _db.LineBindingCodes
.FirstOrDefaultAsync(c => c.Code == normalized && c.ConsumedAt == null && c.ExpiresAt > now, ct);
if (bindingCode is null)
return new LineBindingResult(false, "綁定碼無效或已過期。", null);
bindingCode.ConsumedAt = now;
var existing = await _db.MemberChannelBindings
.FirstOrDefaultAsync(b => b.Channel == Channel && b.MemberId == bindingCode.MemberId, ct);
if (existing is null)
{
_db.MemberChannelBindings.Add(new MemberChannelBinding
{
MemberId = bindingCode.MemberId,
Channel = Channel,
ExternalId = externalId,
BoundAt = now,
});
}
else
{
existing.ExternalId = externalId;
existing.BoundAt = now;
}
await _db.SaveChangesAsync(ct);
return new LineBindingResult(true, "綁定成功!", bindingCode.MemberId);
}
public async Task RegisterGroupAsync(string externalId, CancellationToken ct = default)
{
var group = await _db.MessagingGroups
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
if (group is null)
{
_db.MessagingGroups.Add(new MessagingGroup
{
Channel = Channel,
ExternalId = externalId,
IsActive = true,
RegisteredAt = DateTime.UtcNow,
});
}
else
{
group.IsActive = true;
}
await _db.SaveChangesAsync(ct);
}
public async Task DeactivateGroupAsync(string externalId, CancellationToken ct = default)
{
var group = await _db.MessagingGroups
.FirstOrDefaultAsync(g => g.Channel == Channel && g.ExternalId == externalId, ct);
if (group is not null)
{
group.IsActive = false;
await _db.SaveChangesAsync(ct);
}
}
private static NotificationLog BuildLog(string targetType, string externalId, string body,
string sentBy, MessageSendResult result, int? memberId = null, int? groupId = null) => new()
{
Channel = Channel,
TargetType = targetType,
TargetExternalId = externalId,
MemberId = memberId,
MessagingGroupId = groupId,
Body = 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;
/// <summary>Top-level Line webhook payload (deserialized case-insensitively).</summary>
public sealed class LineWebhookPayload
{
public List<LineWebhookEvent>? Events { get; set; }
}
public sealed class LineWebhookEvent
{
public string? Type { get; set; } // follow | message | join | leave | ...
public string? ReplyToken { get; set; }
public LineWebhookSource? Source { get; set; }
public LineWebhookMessage? Message { get; set; }
}
public sealed class LineWebhookSource
{
public string? Type { get; set; } // user | group | room
public string? UserId { get; set; }
public string? GroupId { get; set; }
}
public sealed class LineWebhookMessage
{
public string? Type { get; set; } // text | image | ...
public string? Text { get; set; }
}
```
- [ ] **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;
/// <summary>
/// Anonymous Line webhook. Verifies the X-Line-Signature over the raw body, then dispatches
/// follow/message/join/leave events. Always returns 200 for valid payloads so Line does not retry;
/// returns 400 only on signature failure.
/// </summary>
[ApiController]
[Route("api/line")]
[AllowAnonymous]
public sealed class LineWebhookController : ControllerBase
{
private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true };
private readonly ILineNotificationService _line;
private readonly IMessageChannel _channel;
private readonly LineOptions _options;
public LineWebhookController(
ILineNotificationService line, IMessageChannel channel, IOptions<LineOptions> options)
{
_line = line;
_channel = channel;
_options = options.Value;
}
[HttpPost("webhook")]
public async Task<IActionResult> Webhook(CancellationToken ct)
{
using var reader = new StreamReader(Request.Body, Encoding.UTF8);
var rawBody = await reader.ReadToEndAsync(ct);
var signature = Request.Headers["X-Line-Signature"].FirstOrDefault();
if (!LineSignature.IsValid(_options.ChannelSecret, Encoding.UTF8.GetBytes(rawBody), signature))
return BadRequest();
var payload = JsonSerializer.Deserialize<LineWebhookPayload>(rawBody, JsonOpts);
if (payload?.Events is not null)
foreach (var evt in payload.Events)
await DispatchAsync(evt, ct);
return Ok();
}
private async Task DispatchAsync(LineWebhookEvent evt, CancellationToken ct)
{
switch (evt.Type)
{
case "follow":
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "歡迎!請輸入您的綁定碼以連結教會帳號。", ct);
break;
case "message":
if (evt.Message?.Type == "text"
&& evt.Source?.UserId is { } userId
&& evt.Message.Text is { } text)
{
var result = await _line.TryBindMemberAsync(userId, text, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, result.Message, ct);
}
break;
case "join":
if (evt.Source?.GroupId is { } joinGroupId)
{
await _line.RegisterGroupAsync(joinGroupId, ct);
if (evt.ReplyToken is not null)
await _channel.ReplyAsync(evt.ReplyToken, "已加入群組,請至後台命名此群組。", ct);
}
break;
case "leave":
if (evt.Source?.GroupId is { } leaveGroupId)
await _line.DeactivateGroupAsync(leaveGroupId, ct);
break;
}
}
}
```
- [ ] **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;
/// <summary>
/// Admin endpoints for the notification module (API-only phase). Binding-code generation, group
/// management, send history, and manual send — the manual send endpoints are the only way to fire
/// a message before a UI exists; programmatic callers use the services directly.
/// </summary>
[ApiController]
[Route("api/notifications")]
[Authorize]
public sealed class NotificationsController : ControllerBase
{
private readonly IEmailService _email;
private readonly ILineNotificationService _line;
private readonly AppDbContext _db;
private readonly CurrentUserAccessor _currentUser;
public NotificationsController(
IEmailService email, ILineNotificationService line,
AppDbContext db, CurrentUserAccessor currentUser)
{
_email = email;
_line = line;
_db = db;
_currentUser = currentUser;
}
[HttpPost("members/{id:int}/line-binding-code")]
public async Task<IActionResult> GenerateBindingCode(int id, CancellationToken ct)
=> Ok(new { code = await _line.GenerateLineBindingCodeAsync(id, ct) });
[HttpGet("groups")]
public async Task<IActionResult> Groups(CancellationToken ct)
=> Ok(await _db.MessagingGroups
.OrderBy(g => g.Id)
.Select(g => new { g.Id, g.Name, g.IsActive, g.RegisteredAt })
.ToListAsync(ct));
[HttpPut("groups/{id:int}")]
public async Task<IActionResult> UpdateGroup(int id, [FromBody] UpdateGroupRequest request, CancellationToken ct)
{
var group = await _db.MessagingGroups.FirstOrDefaultAsync(g => g.Id == id, ct);
if (group is null) return NotFound();
group.Name = request.Name;
group.IsActive = request.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpGet("history")]
public async Task<IActionResult> History(
[FromQuery] int page = 1, [FromQuery] int pageSize = 50, CancellationToken ct = default)
{
var size = Math.Clamp(pageSize, 1, 200);
var skip = (Math.Max(page, 1) - 1) * size;
var query = _db.NotificationLogs.OrderByDescending(l => l.SentAt);
var total = await query.CountAsync(ct);
var items = await query
.Skip(skip).Take(size)
.Select(l => new
{
l.Id, l.Channel, l.TargetType, l.TargetExternalId, l.Subject,
l.Status, l.Error, l.SentByUserId, l.SentAt,
})
.ToListAsync(ct);
return Ok(new { total, items });
}
[HttpPost("send-line")]
public async Task<IActionResult> SendLine([FromBody] SendLineRequest request, CancellationToken ct)
=> Ok(await _line.SendLineAsync(
request.Body, request.MemberIds ?? [], request.GroupIds ?? [],
_currentUser.UserIdOrSystem, ct));
[HttpPost("send-email")]
public async Task<IActionResult> SendEmail([FromBody] SendEmailRequest request, CancellationToken ct)
=> Ok(await _email.SendAsync(new EmailMessage(
MemberIds: request.MemberIds ?? [],
Addresses: request.Addresses ?? [],
Subject: request.Subject,
HtmlBody: request.HtmlBody,
Attachments: null,
SentByUserId: _currentUser.UserIdOrSystem), ct));
}
```
- [ ] **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<IMealAttendanceService, MealAttendanceService>();` line), add:
```csharp
// ── Notifications (email via SMTP + Line) ──────────────────────────────────
builder.Services.Configure<ROLAC.API.Services.Notifications.SmtpOptions>(config.GetSection("Smtp"));
builder.Services.Configure<ROLAC.API.Services.Notifications.LineOptions>(config.GetSection("Line"));
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ISmtpDispatcher,
ROLAC.API.Services.Notifications.MailKitSmtpDispatcher>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IEmailService,
ROLAC.API.Services.Notifications.EmailService>();
builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationService,
ROLAC.API.Services.Notifications.LineNotificationService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
ROLAC.API.Services.Notifications.LineMessageChannel>();
```
- [ ] **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://<domain>/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 56; Line service + binding/groups (§4.2, §4.5) → Tasks 79; `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.