583408032d
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>
1818 lines
68 KiB
Markdown
1818 lines
68 KiB
Markdown
# 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 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.
|