diff --git a/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs index 801fa63..29b2ee9 100644 --- a/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs +++ b/API/ROLAC.API.Tests/Services/Notifications/LineMessageChannelTests.cs @@ -12,6 +12,7 @@ public class LineMessageChannelTests { public SmtpOptions GetSmtp() => new(); public LineOptions GetLine() => new() { ChannelAccessToken = "tok", ChannelSecret = "sec" }; + public WebPushOptions GetWebPush() => new(); public void Reload() { } } diff --git a/API/ROLAC.API.Tests/Services/Notifications/WebPushServiceTests.cs b/API/ROLAC.API.Tests/Services/Notifications/WebPushServiceTests.cs new file mode 100644 index 0000000..046d13d --- /dev/null +++ b/API/ROLAC.API.Tests/Services/Notifications/WebPushServiceTests.cs @@ -0,0 +1,143 @@ +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 WebPushServiceTests +{ + // Records the endpoints it was asked to push to; can be told to fail or to report "gone" for one. + private sealed class FakeWebPushSender : IWebPushSender + { + public List SentToEndpoints { get; } = new(); + public string? ExpireForEndpoint { get; set; } + public string? FailForEndpoint { get; set; } + + public Task SendAsync(WebPushTarget target, string payloadJson, CancellationToken ct = default) + { + if (target.Endpoint == ExpireForEndpoint) + return Task.FromResult(new WebPushSendResult(Success: false, IsExpired: true, Error: "gone")); + if (target.Endpoint == FailForEndpoint) + return Task.FromResult(new WebPushSendResult(Success: false, IsExpired: false, Error: "transient")); + SentToEndpoints.Add(target.Endpoint); + return Task.FromResult(new WebPushSendResult(Success: true, IsExpired: false, Error: null)); + } + } + + private static CurrentUserAccessor BuildAccessor(string userId = "test-user") + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId) }; + var ctx = new DefaultHttpContext { User = new(new ClaimsIdentity(claims)) }; + var mock = new Mock(); + mock.Setup(x => x.HttpContext).Returns(ctx); + return new CurrentUserAccessor(mock.Object); + } + + private static AppDbContext BuildDb() + { + var interceptor = new AuditSaveChangesInterceptor(BuildAccessor()); + return new AppDbContext( + new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptor) + .Options); + } + + private static async Task SeedMemberAsync(AppDbContext db) + { + var member = new Member { FirstName_en = "Test", LastName_en = "User" }; + db.Members.Add(member); + await db.SaveChangesAsync(); + return member.Id; + } + + private static async Task SeedSubscriptionAsync(AppDbContext db, int memberId, string endpoint) + { + db.WebPushSubscriptions.Add(new WebPushSubscription + { + MemberId = memberId, Endpoint = endpoint, P256dh = "key", Auth = "auth", CreatedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(); + } + + private static WebPushPayload Payload() => new("Title", "Body", "/user-portal/finance/expenses"); + + [Fact] + public async Task SendToMembersAsync_PushesToEveryDeviceSubscription_AndLogsEach() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + await SeedSubscriptionAsync(db, memberId, "https://push.example/phone"); + await SeedSubscriptionAsync(db, memberId, "https://push.example/laptop"); + var sender = new FakeWebPushSender(); + var service = new WebPushService(db, sender, BuildAccessor()); + + var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); + + Assert.Equal(2, result.SentCount); + Assert.Equal(0, result.FailedCount); + Assert.Equal(2, sender.SentToEndpoints.Count); + Assert.Equal(2, await db.NotificationLogs.CountAsync(l => + l.Channel == NotificationChannels.WebPush && l.Status == NotificationStatuses.Sent)); + } + + [Fact] + public async Task SendToMembersAsync_PrunesSubscription_WhenPushServiceReportsGone() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + await SeedSubscriptionAsync(db, memberId, "https://push.example/dead"); + await SeedSubscriptionAsync(db, memberId, "https://push.example/live"); + var sender = new FakeWebPushSender { ExpireForEndpoint = "https://push.example/dead" }; + var service = new WebPushService(db, sender, BuildAccessor()); + + var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); + + Assert.Equal(1, result.SentCount); + Assert.Equal(1, result.FailedCount); + var remaining = await db.WebPushSubscriptions.Select(s => s.Endpoint).ToListAsync(); + Assert.Equal(new[] { "https://push.example/live" }, remaining); + } + + [Fact] + public async Task SendToMembersAsync_LogsTransientFailure_WithoutPruning_OrAborting() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + await SeedSubscriptionAsync(db, memberId, "https://push.example/flaky"); + await SeedSubscriptionAsync(db, memberId, "https://push.example/ok"); + var sender = new FakeWebPushSender { FailForEndpoint = "https://push.example/flaky" }; + var service = new WebPushService(db, sender, BuildAccessor()); + + var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); + + Assert.Equal(1, result.SentCount); + Assert.Equal(1, result.FailedCount); + Assert.Single(result.Failures); + Assert.Equal(2, await db.WebPushSubscriptions.CountAsync()); // transient failure does NOT prune + Assert.Equal(1, await db.NotificationLogs.CountAsync(l => l.Status == NotificationStatuses.Failed)); + } + + [Fact] + public async Task SendToMembersAsync_ReturnsEmpty_WhenMemberHasNoSubscription() + { + using var db = BuildDb(); + var memberId = await SeedMemberAsync(db); + var sender = new FakeWebPushSender(); + var service = new WebPushService(db, sender, BuildAccessor()); + + var result = await service.SendToMembersAsync(new[] { memberId }, Payload(), "test-user"); + + Assert.Equal(0, result.SentCount); + Assert.Empty(sender.SentToEndpoints); + Assert.Equal(0, await db.NotificationLogs.CountAsync()); + } +} diff --git a/API/ROLAC.API/Controllers/NotificationsController.cs b/API/ROLAC.API/Controllers/NotificationsController.cs index 972d686..d070af8 100644 --- a/API/ROLAC.API/Controllers/NotificationsController.cs +++ b/API/ROLAC.API/Controllers/NotificationsController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using ROLAC.API.Authorization; using ROLAC.API.Data; using ROLAC.API.DTOs.Notifications; using ROLAC.API.Services.Logging; @@ -20,15 +21,17 @@ public sealed class NotificationsController : ControllerBase { private readonly IEmailService _email; private readonly ILineNotificationService _line; + private readonly IWebPushService _webPush; private readonly AppDbContext _db; private readonly CurrentUserAccessor _currentUser; public NotificationsController( - IEmailService email, ILineNotificationService line, + IEmailService email, ILineNotificationService line, IWebPushService webPush, AppDbContext db, CurrentUserAccessor currentUser) { _email = email; _line = line; + _webPush = webPush; _db = db; _currentUser = currentUser; } @@ -92,4 +95,14 @@ public sealed class NotificationsController : ControllerBase HtmlBody: request.HtmlBody, Attachments: null, SentByUserId: _currentUser.UserIdOrSystem), ct)); + + // Manual one-to-(few) Web Push send. Guarded by Settings:Write (sending is an admin capability); + // the Member Management "send push" test action calls this for a single member. + [HttpPost("send-webpush")] + [HasPermission(Modules.Settings, PermissionActions.Write)] + public async Task SendWebPush([FromBody] SendWebPushRequest request, CancellationToken ct) + => Ok(await _webPush.SendToMembersAsync( + request.MemberIds ?? [], + new WebPushPayload(request.Title, request.Body, request.Url), + _currentUser.UserIdOrSystem, ct)); } diff --git a/API/ROLAC.API/Controllers/PushSubscriptionsController.cs b/API/ROLAC.API/Controllers/PushSubscriptionsController.cs new file mode 100644 index 0000000..3d6ce19 --- /dev/null +++ b/API/ROLAC.API/Controllers/PushSubscriptionsController.cs @@ -0,0 +1,110 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Push; +using ROLAC.API.Entities.Notifications; +using ROLAC.API.Services.Logging; +using ROLAC.API.Services.Notifications; + +namespace ROLAC.API.Controllers; + +/// +/// Self-service Web Push subscription management for the logged-in member: hand out the VAPID public +/// key, store the browser's subscription, and remove it on opt-out. Subscriptions are keyed to the +/// caller's linked Member, so an admin-only account (no MemberId) cannot subscribe. +/// +[ApiController] +[Route("api/push")] +[Authorize] +public sealed class PushSubscriptionsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly INotificationSettingsService _settings; + private readonly IWebPushService _webPush; + private readonly CurrentUserAccessor _currentUser; + + public PushSubscriptionsController( + AppDbContext db, INotificationSettingsService settings, + IWebPushService webPush, CurrentUserAccessor currentUser) + { + _db = db; + _settings = settings; + _webPush = webPush; + _currentUser = currentUser; + } + + [HttpGet("vapid-public-key")] + public IActionResult VapidPublicKey() + => Ok(new { publicKey = _settings.GetWebPush().PublicKey }); + + [HttpPost("subscriptions")] + public async Task Subscribe([FromBody] PushSubscriptionRequest request, CancellationToken ct) + { + var memberId = await CurrentMemberIdAsync(ct); + if (memberId is null) + return Conflict(new { message = "This account is not linked to a member, so it cannot receive push notifications." }); + + var existing = await _db.WebPushSubscriptions + .FirstOrDefaultAsync(subscription => subscription.Endpoint == request.Endpoint, ct); + + if (existing is null) + { + _db.WebPushSubscriptions.Add(new WebPushSubscription + { + MemberId = memberId.Value, + Endpoint = request.Endpoint, + P256dh = request.Keys.P256dh, + Auth = request.Keys.Auth, + UserAgent = request.UserAgent, + CreatedAt = DateTime.UtcNow, + }); + } + else + { + // Same endpoint re-subscribed (e.g. keys rotated, or now a different member on this browser). + existing.MemberId = memberId.Value; + existing.P256dh = request.Keys.P256dh; + existing.Auth = request.Keys.Auth; + existing.UserAgent = request.UserAgent; + } + + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("subscriptions")] + public async Task Unsubscribe([FromBody] PushUnsubscribeRequest request, CancellationToken ct) + { + var subscription = await _db.WebPushSubscriptions + .FirstOrDefaultAsync(s => s.Endpoint == request.Endpoint, ct); + if (subscription is not null) + { + _db.WebPushSubscriptions.Remove(subscription); + await _db.SaveChangesAsync(ct); + } + return NoContent(); + } + + // Sends a canned push to the caller's own devices so they can confirm notifications work. + [HttpPost("test")] + public async Task SendTestToSelf(CancellationToken ct) + { + var memberId = await CurrentMemberIdAsync(ct); + if (memberId is null) + return Conflict(new { message = "This account is not linked to a member, so it cannot receive push notifications." }); + + var result = await _webPush.SendToMembersAsync( + new[] { memberId.Value }, + new WebPushPayload("River of Life", "通知測試成功!This is a test notification.", "/user-portal/account"), + _currentUser.UserIdOrSystem, ct); + return Ok(result); + } + + private async Task CurrentMemberIdAsync(CancellationToken ct) + { + var userId = _currentUser.UserId; + if (string.IsNullOrEmpty(userId)) return null; + return await _db.Users.Where(user => user.Id == userId).Select(user => user.MemberId).FirstOrDefaultAsync(ct); + } +} diff --git a/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs b/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs index 903954f..96093b1 100644 --- a/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs +++ b/API/ROLAC.API/DTOs/Notifications/NotificationRequests.cs @@ -5,3 +5,6 @@ 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); + +/// Admin manual Web Push send to one or more members (used by the Member Management test action). +public sealed record SendWebPushRequest(int[]? MemberIds, string Title, string Body, string? Url); diff --git a/API/ROLAC.API/DTOs/Push/PushSubscriptionDtos.cs b/API/ROLAC.API/DTOs/Push/PushSubscriptionDtos.cs new file mode 100644 index 0000000..1a3f613 --- /dev/null +++ b/API/ROLAC.API/DTOs/Push/PushSubscriptionDtos.cs @@ -0,0 +1,9 @@ +namespace ROLAC.API.DTOs.Push; + +/// The PushSubscription a browser produces, posted when a member enables notifications. +public sealed record PushSubscriptionRequest(string Endpoint, PushSubscriptionKeys Keys, string? UserAgent); + +public sealed record PushSubscriptionKeys(string P256dh, string Auth); + +/// Identifies the subscription to remove when a member disables notifications. +public sealed record PushUnsubscribeRequest(string Endpoint); diff --git a/API/ROLAC.API/Data/AppDbContext.cs b/API/ROLAC.API/Data/AppDbContext.cs index 7c946bd..071609e 100644 --- a/API/ROLAC.API/Data/AppDbContext.cs +++ b/API/ROLAC.API/Data/AppDbContext.cs @@ -38,6 +38,7 @@ public class AppDbContext : IdentityDbContext public DbSet LineBindingCodes => Set(); public DbSet MessagingGroups => Set(); public DbSet NotificationLogs => Set(); + public DbSet WebPushSubscriptions => Set(); public DbSet SiteSettings => Set(); public DbSet NotificationSettings => Set(); @@ -560,6 +561,18 @@ public class AppDbContext : IdentityDbContext .HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull); }); + builder.Entity(entity => + { + entity.Property(e => e.Endpoint).HasMaxLength(2000).IsRequired(); + entity.Property(e => e.P256dh).HasMaxLength(200).IsRequired(); + entity.Property(e => e.Auth).HasMaxLength(100).IsRequired(); + entity.Property(e => e.UserAgent).HasMaxLength(400); + entity.HasIndex(e => e.Endpoint).IsUnique(); + entity.HasIndex(e => e.MemberId); + entity.HasOne(e => e.Member).WithMany() + .HasForeignKey(e => e.MemberId).OnDelete(DeleteBehavior.Cascade); + }); + // ── SystemLog / AuditLog (append-only) ─────────────────────────────── // Mapped here for SCHEMA only — there are deliberately no DbSets on this // context, so business code can't write logs through the audited context. diff --git a/API/ROLAC.API/Data/DbSeeder.cs b/API/ROLAC.API/Data/DbSeeder.cs index b4d880f..cc0fde7 100644 --- a/API/ROLAC.API/Data/DbSeeder.cs +++ b/API/ROLAC.API/Data/DbSeeder.cs @@ -474,6 +474,20 @@ public static class DbSeeder }); await db.SaveChangesAsync(); } + + // Generate the VAPID key pair once so Web Push works out of the box. Persisted on the + // singleton row; the public key is handed to browsers, the private key signs each push. + var row = await db.NotificationSettings.OrderBy(s => s.Id).FirstAsync(); + if (string.IsNullOrWhiteSpace(row.VapidPublicKey)) + { + var keys = WebPush.VapidHelper.GenerateVapidKeys(); + row.VapidPublicKey = keys.PublicKey; + row.VapidPrivateKey = keys.PrivateKey; + if (string.IsNullOrWhiteSpace(row.VapidSubject)) + row.VapidSubject = "mailto:admin@rolac.local"; + row.EnableWebPush = true; + await db.SaveChangesAsync(); + } } /// diff --git a/API/ROLAC.API/Entities/NotificationSetting.cs b/API/ROLAC.API/Entities/NotificationSetting.cs index 12bb96e..d7a1e64 100644 --- a/API/ROLAC.API/Entities/NotificationSetting.cs +++ b/API/ROLAC.API/Entities/NotificationSetting.cs @@ -29,4 +29,12 @@ public class NotificationSetting : AuditableEntity, IAuditable public bool EnableLine { get; set; } public string LineChannelAccessToken { get; set; } = ""; public string LineChannelSecret { get; set; } = ""; + + // ── Web Push (PWA browser notifications) ─────────────────────────────────── + // VAPID keys identify this server to the push services. The pair is generated once on first + // startup if empty; the public key is safe to hand to the browser, the private key is a secret. + public bool EnableWebPush { get; set; } + public string VapidPublicKey { get; set; } = ""; + public string VapidPrivateKey { get; set; } = ""; + public string VapidSubject { get; set; } = ""; // "mailto:..." or the site URL } diff --git a/API/ROLAC.API/Entities/Notifications/WebPushSubscription.cs b/API/ROLAC.API/Entities/Notifications/WebPushSubscription.cs new file mode 100644 index 0000000..0ff041d --- /dev/null +++ b/API/ROLAC.API/Entities/Notifications/WebPushSubscription.cs @@ -0,0 +1,33 @@ +using ROLAC.API.Entities; + +namespace ROLAC.API.Entities.Notifications; + +/// +/// A browser/device Web Push subscription owned by a member. Unlike +/// (one external id per channel), web push needs the endpoint URL plus two keys, and a member can +/// have several — one per device/browser they enable notifications on. Targeting a member resolves +/// all of their subscriptions. +/// +public class WebPushSubscription +{ + public int Id { get; set; } + public int MemberId { get; set; } + public Member? Member { get; set; } + + /// The push service endpoint URL (unique per subscription). + public string Endpoint { get; set; } = null!; + + /// The subscription's P-256 ECDH public key (base64url). + public string P256dh { get; set; } = null!; + + /// The subscription's auth secret (base64url). + public string Auth { get; set; } = null!; + + /// The originating user agent, kept for display/debugging. + public string? UserAgent { get; set; } + + public DateTime CreatedAt { get; set; } + + /// Last time a push to this subscription succeeded. + public DateTime? LastUsedAt { get; set; } +} diff --git a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs index f252461..5776ee0 100644 --- a/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs +++ b/API/ROLAC.API/Migrations/AppDbContextModelSnapshot.cs @@ -1704,6 +1704,9 @@ namespace ROLAC.API.Migrations b.Property("EnableLine") .HasColumnType("boolean"); + b.Property("EnableWebPush") + .HasColumnType("boolean"); + b.Property("FromAddress") .IsRequired() .HasMaxLength(200) @@ -1753,6 +1756,18 @@ namespace ROLAC.API.Migrations .HasMaxLength(450) .HasColumnType("character varying(450)"); + b.Property("VapidPrivateKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("VapidPublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("VapidSubject") + .IsRequired() + .HasColumnType("text"); + b.HasKey("Id"); b.ToTable("NotificationSettings"); @@ -1926,6 +1941,52 @@ namespace ROLAC.API.Migrations b.ToTable("NotificationLogs"); }); + modelBuilder.Entity("ROLAC.API.Entities.Notifications.WebPushSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Auth") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Endpoint") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LastUsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("integer"); + + b.Property("P256dh") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserAgent") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.HasKey("Id"); + + b.HasIndex("Endpoint") + .IsUnique(); + + b.HasIndex("MemberId"); + + b.ToTable("WebPushSubscriptions"); + }); + modelBuilder.Entity("ROLAC.API.Entities.OfferingSession", b => { b.Property("Id") @@ -2599,6 +2660,17 @@ namespace ROLAC.API.Migrations b.Navigation("MessagingGroup"); }); + modelBuilder.Entity("ROLAC.API.Entities.Notifications.WebPushSubscription", b => + { + b.HasOne("ROLAC.API.Entities.Member", "Member") + .WithMany() + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Member"); + }); + modelBuilder.Entity("ROLAC.API.Entities.Payee1099", b => { b.HasOne("ROLAC.API.Entities.Member", "Member") diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 52dd0b6..998bebf 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -188,6 +188,10 @@ builder.Services.AddScoped(); builder.Services.AddHttpClient(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); // ── AI assist (expense translation + category suggestion) ────────────────── // Backend proxy so the API key stays server-side. Provider + model + key come from the diff --git a/API/ROLAC.API/ROLAC.API.csproj b/API/ROLAC.API/ROLAC.API.csproj index 39e6945..8888843 100644 --- a/API/ROLAC.API/ROLAC.API.csproj +++ b/API/ROLAC.API/ROLAC.API.csproj @@ -26,6 +26,7 @@ + \ No newline at end of file diff --git a/API/ROLAC.API/Services/Notifications/IWebPushSender.cs b/API/ROLAC.API/Services/Notifications/IWebPushSender.cs new file mode 100644 index 0000000..43a0398 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/IWebPushSender.cs @@ -0,0 +1,19 @@ +namespace ROLAC.API.Services.Notifications; + +/// The three fields a browser hands over when it creates a push subscription. +public sealed record WebPushTarget(string Endpoint, string P256dh, string Auth); + +/// +/// Outcome of pushing to one subscription. is true when the push service +/// reported the subscription is gone (HTTP 404/410) so the caller can prune it. +/// +public sealed record WebPushSendResult(bool Success, bool IsExpired, string? Error); + +/// +/// Thin wrapper over the WebPush library's encrypt-and-POST so that 's +/// recipient resolution, pruning, and logging can be unit-tested without a real push service. +/// +public interface IWebPushSender +{ + Task SendAsync(WebPushTarget target, string payloadJson, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/IWebPushService.cs b/API/ROLAC.API/Services/Notifications/IWebPushService.cs new file mode 100644 index 0000000..8098433 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/IWebPushService.cs @@ -0,0 +1,13 @@ +namespace ROLAC.API.Services.Notifications; + +/// +/// The Web Push channel — peer to and . +/// Resolves every device subscription of the target members and pushes one notification to each, +/// pruning subscriptions the push service reports as gone and writing a NotificationLog per send. +/// +public interface IWebPushService +{ + Task SendToMembersAsync( + IReadOnlyCollection memberIds, WebPushPayload payload, + string sentByUserId, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/Services/Notifications/NotificationModels.cs b/API/ROLAC.API/Services/Notifications/NotificationModels.cs index d9c464f..969a4f1 100644 --- a/API/ROLAC.API/Services/Notifications/NotificationModels.cs +++ b/API/ROLAC.API/Services/Notifications/NotificationModels.cs @@ -3,8 +3,9 @@ namespace ROLAC.API.Services.Notifications; /// Canonical channel discriminators stored in NotificationLog.Channel. public static class NotificationChannels { - public const string Email = "email"; - public const string Line = "line"; + public const string Email = "email"; + public const string Line = "line"; + public const string WebPush = "webpush"; } /// Canonical target-type discriminators stored in NotificationLog.TargetType. @@ -36,6 +37,12 @@ public sealed record NotificationResult( /// A file attached to an outbound email. public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content); +/// +/// The content of a single Web Push notification. is the in-app path to open when +/// the notification is clicked; lets a newer notification replace an older one. +/// +public sealed record WebPushPayload(string Title, string Body, string? Url = null, string? Tag = null); + /// /// 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. diff --git a/API/ROLAC.API/Services/Notifications/NotificationOptions.cs b/API/ROLAC.API/Services/Notifications/NotificationOptions.cs index ab6e8f0..121f9db 100644 --- a/API/ROLAC.API/Services/Notifications/NotificationOptions.cs +++ b/API/ROLAC.API/Services/Notifications/NotificationOptions.cs @@ -18,3 +18,11 @@ public sealed class LineOptions public string ChannelAccessToken { get; set; } = ""; public string ChannelSecret { get; set; } = ""; } + +/// VAPID settings for Web Push. Sourced from the NotificationSetting row at runtime. +public sealed class WebPushOptions +{ + public string PublicKey { get; set; } = ""; + public string PrivateKey { get; set; } = ""; + public string Subject { get; set; } = ""; +} diff --git a/API/ROLAC.API/Services/Notifications/NotificationSettingsService.cs b/API/ROLAC.API/Services/Notifications/NotificationSettingsService.cs index 49b0b79..54a46f7 100644 --- a/API/ROLAC.API/Services/Notifications/NotificationSettingsService.cs +++ b/API/ROLAC.API/Services/Notifications/NotificationSettingsService.cs @@ -15,6 +15,7 @@ public interface INotificationSettingsService { SmtpOptions GetSmtp(); LineOptions GetLine(); + WebPushOptions GetWebPush(); void Reload(); } @@ -27,6 +28,7 @@ public sealed class NotificationSettingsService : INotificationSettingsService private SmtpOptions? _smtp; private LineOptions? _line; + private WebPushOptions? _webPush; public NotificationSettingsService( IServiceScopeFactory scopeFactory, @@ -50,12 +52,19 @@ public sealed class NotificationSettingsService : INotificationSettingsService return _line!; } + public WebPushOptions GetWebPush() + { + EnsureLoaded(); + return _webPush!; + } + public void Reload() { lock (_gate) { _smtp = null; _line = null; + _webPush = null; } } @@ -63,7 +72,7 @@ public sealed class NotificationSettingsService : INotificationSettingsService { lock (_gate) { - if (_smtp is not null && _line is not null) + if (_smtp is not null && _line is not null && _webPush is not null) return; using var scope = _scopeFactory.CreateScope(); @@ -72,9 +81,11 @@ public sealed class NotificationSettingsService : INotificationSettingsService if (row is null) { - // Not seeded yet — use the appsettings values so sends still work. + // Not seeded yet — use the appsettings values so sends still work. Web push has no + // appsettings fallback; it stays disabled until the row exists with VAPID keys. _smtp = _smtpFallback.Value; _line = _lineFallback.Value; + _webPush = new WebPushOptions(); return; } @@ -93,6 +104,12 @@ public sealed class NotificationSettingsService : INotificationSettingsService ChannelAccessToken = row.LineChannelAccessToken, ChannelSecret = row.LineChannelSecret, }; + _webPush = new WebPushOptions + { + PublicKey = row.VapidPublicKey, + PrivateKey = row.VapidPrivateKey, + Subject = row.VapidSubject, + }; } } } diff --git a/API/ROLAC.API/Services/Notifications/WebPushSender.cs b/API/ROLAC.API/Services/Notifications/WebPushSender.cs new file mode 100644 index 0000000..68f43b0 --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/WebPushSender.cs @@ -0,0 +1,40 @@ +using System.Net; +using WebPush; + +namespace ROLAC.API.Services.Notifications; + +/// +/// Sends one encrypted Web Push message via the WebPush library, signing with the VAPID keys from +/// the notification settings. Translates a "subscription gone" (404/410) response into +/// so the caller prunes the dead subscription. +/// +public sealed class WebPushSender : IWebPushSender +{ + private readonly INotificationSettingsService _settings; + private readonly WebPushClient _client = new(); + + public WebPushSender(INotificationSettingsService settings) => _settings = settings; + + public async Task SendAsync( + WebPushTarget target, string payloadJson, CancellationToken ct = default) + { + var options = _settings.GetWebPush(); + if (string.IsNullOrWhiteSpace(options.PublicKey) || string.IsNullOrWhiteSpace(options.PrivateKey)) + return new WebPushSendResult(Success: false, IsExpired: false, Error: "Web Push is not configured (missing VAPID keys)."); + + var subject = string.IsNullOrWhiteSpace(options.Subject) ? "mailto:admin@rolac.local" : options.Subject; + var vapid = new VapidDetails(subject, options.PublicKey, options.PrivateKey); + var subscription = new PushSubscription(target.Endpoint, target.P256dh, target.Auth); + + try + { + await _client.SendNotificationAsync(subscription, payloadJson, vapid, ct); + return new WebPushSendResult(Success: true, IsExpired: false, Error: null); + } + catch (WebPushException ex) + { + var expired = ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone; + return new WebPushSendResult(Success: false, IsExpired: expired, Error: ex.Message); + } + } +} diff --git a/API/ROLAC.API/Services/Notifications/WebPushService.cs b/API/ROLAC.API/Services/Notifications/WebPushService.cs new file mode 100644 index 0000000..916588c --- /dev/null +++ b/API/ROLAC.API/Services/Notifications/WebPushService.cs @@ -0,0 +1,111 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.Entities.Notifications; +using ROLAC.API.Services.Logging; + +namespace ROLAC.API.Services.Notifications; + +/// +/// Resolves the target members' device subscriptions, pushes one notification to each via the +/// , prunes any subscription the push service reports as gone (404/410), +/// and writes a NotificationLog row per send. A single failure never aborts the batch — it is +/// recorded and reported in the summary (mirrors ). +/// +public sealed class WebPushService : IWebPushService +{ + // The endpoint URL can be long; NotificationLog.TargetExternalId is capped at 200. + private const int TargetExternalIdMaxLength = 200; + + private readonly AppDbContext _db; + private readonly IWebPushSender _sender; + private readonly CurrentUserAccessor _currentUser; + + public WebPushService(AppDbContext db, IWebPushSender sender, CurrentUserAccessor currentUser) + { + _db = db; + _sender = sender; + _currentUser = currentUser; + } + + public async Task SendToMembersAsync( + IReadOnlyCollection memberIds, WebPushPayload payload, + string sentByUserId, CancellationToken ct = default) + { + if (memberIds.Count == 0) return NotificationResult.Empty; + + var subscriptions = await _db.WebPushSubscriptions + .Where(subscription => memberIds.Contains(subscription.MemberId)) + .ToListAsync(ct); + if (subscriptions.Count == 0) return NotificationResult.Empty; + + var sentBy = string.IsNullOrEmpty(sentByUserId) ? _currentUser.UserIdOrSystem : sentByUserId; + var payloadJson = BuildPayloadJson(payload); + var failures = new List(); + var expired = new List(); + var sentCount = 0; + + foreach (var subscription in subscriptions) + { + var log = new NotificationLog + { + Channel = NotificationChannels.WebPush, + TargetType = NotificationTargetTypes.User, + TargetExternalId = TruncateEndpoint(subscription.Endpoint), + Subject = payload.Title, + MemberId = subscription.MemberId, + Body = NotificationLogText.Truncate(payload.Body), + SentByUserId = sentBy, + SentAt = DateTime.UtcNow, + }; + + var result = await _sender.SendAsync( + new WebPushTarget(subscription.Endpoint, subscription.P256dh, subscription.Auth), payloadJson, ct); + + if (result.Success) + { + log.Status = NotificationStatuses.Sent; + subscription.LastUsedAt = DateTime.UtcNow; + sentCount++; + } + else + { + log.Status = NotificationStatuses.Failed; + log.Error = result.Error; + failures.Add(new NotificationFailure(log.TargetExternalId, result.Error ?? "unknown error")); + if (result.IsExpired) expired.Add(subscription); + } + + _db.NotificationLogs.Add(log); + } + + // Drop subscriptions the push service says are gone so they don't linger and fail forever. + if (expired.Count > 0) _db.WebPushSubscriptions.RemoveRange(expired); + + await _db.SaveChangesAsync(ct); + return new NotificationResult(sentCount, failures.Count, failures); + } + + // Shape expected by @angular/service-worker (ngsw): a top-level "notification" object whose + // "data.url" the app reads on click to navigate. "tag" lets a newer message replace an older one. + private static string BuildPayloadJson(WebPushPayload payload) + { + var notification = new Dictionary + { + ["title"] = payload.Title, + ["body"] = payload.Body, + ["icon"] = "assets/AppLogo-192.png", + ["data"] = new Dictionary + { + ["url"] = payload.Url, + ["tag"] = payload.Tag, + }, + }; + if (!string.IsNullOrWhiteSpace(payload.Tag)) notification["tag"] = payload.Tag; + + return JsonSerializer.Serialize(new Dictionary { ["notification"] = notification }); + } + + private static string TruncateEndpoint(string endpoint) => + endpoint.Length <= TargetExternalIdMaxLength ? endpoint : endpoint[..TargetExternalIdMaxLength]; +} diff --git a/APP/angular.json b/APP/angular.json index 341dd64..4b21509 100644 --- a/APP/angular.json +++ b/APP/angular.json @@ -44,6 +44,7 @@ "@angular/localize/init" ], "tsConfig": "tsconfig.app.json", + "serviceWorker": "ngsw-config.json", "assets": [ "src/assets", "src/manifest.webmanifest" diff --git a/APP/ngsw-config.json b/APP/ngsw-config.json new file mode 100644 index 0000000..f8bf210 --- /dev/null +++ b/APP/ngsw-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "./node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.html", + "/manifest.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/**", + "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" + ] + } + } + ] +} diff --git a/APP/package-lock.json b/APP/package-lock.json index 0dd3cf1..c8bc453 100644 --- a/APP/package-lock.json +++ b/APP/package-lock.json @@ -1,11 +1,11 @@ { - "name": "RBJ.Identity.App", + "name": "ROLAC.App", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "RBJ.Identity.App", + "name": "ROLAC.App", "version": "0.0.0", "dependencies": { "@angular/animations": "^20.1.0", @@ -16,6 +16,7 @@ "@angular/localize": "^20.1.6", "@angular/platform-browser": "^20.1.0", "@angular/router": "^20.1.0", + "@angular/service-worker": "^20.3.25", "@microsoft/signalr": "^8.0.17", "@progress/kendo-angular-buttons": "^20.0.0", "@progress/kendo-angular-charts": "^20.0.0", @@ -696,6 +697,25 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/service-worker": { + "version": "20.3.25", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-20.3.25.tgz", + "integrity": "sha512-E9fS9/dukaoOaXxaa6l2BNFvOIJS1j+t+h7ZqU2PITbSqB4F8Go4XBA1bvXBKYcJUX6oaByD0Mu2yy3+mznjGQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "bin": { + "ngsw-config": "ngsw-config.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "20.3.25", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", diff --git a/APP/package.json b/APP/package.json index 4c6e017..0a62985 100644 --- a/APP/package.json +++ b/APP/package.json @@ -30,6 +30,7 @@ "@angular/localize": "^20.1.6", "@angular/platform-browser": "^20.1.0", "@angular/router": "^20.1.0", + "@angular/service-worker": "^20.3.25", "@microsoft/signalr": "^8.0.17", "@progress/kendo-angular-buttons": "^20.0.0", "@progress/kendo-angular-charts": "^20.0.0", @@ -92,4 +93,4 @@ "tailwindcss": "^4.3.0", "typescript": "~5.8.2" } -} \ No newline at end of file +} diff --git a/APP/src/app/app.config.ts b/APP/src/app/app.config.ts index ede750b..873a56c 100644 --- a/APP/src/app/app.config.ts +++ b/APP/src/app/app.config.ts @@ -1,7 +1,8 @@ -import { ApplicationConfig, APP_INITIALIZER } from '@angular/core'; +import { ApplicationConfig, APP_INITIALIZER, isDevMode } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { provideServiceWorker } from '@angular/service-worker'; import { routes } from './app.routes'; import { authInterceptor } from './core/interceptors/auth.interceptor'; @@ -21,5 +22,11 @@ export const appConfig: ApplicationConfig = { deps: [AuthService], multi: true, }, + // Web Push needs the ngsw service worker active. Disabled in dev (it conflicts with the live + // reload); register it shortly after the app stabilises in production builds. + provideServiceWorker('ngsw-worker.js', { + enabled: !isDevMode(), + registrationStrategy: 'registerWhenStable:30000', + }), ] }; diff --git a/APP/src/app/core/services/push-notification.service.ts b/APP/src/app/core/services/push-notification.service.ts new file mode 100644 index 0000000..cdb351b --- /dev/null +++ b/APP/src/app/core/services/push-notification.service.ts @@ -0,0 +1,94 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { SwPush } from '@angular/service-worker'; +import { Observable, firstValueFrom } from 'rxjs'; +import { ApiConfigService } from './api-config.service'; + +interface VapidPublicKeyResponse { + publicKey: string; +} + +/** + * Manages this device's Web Push subscription for the logged-in member: request permission, + * subscribe with the server's VAPID key, persist the subscription on the API, and clean up on + * opt-out. The ngsw service worker shows the notifications; here we only handle the click → route. + */ +@Injectable({ providedIn: 'root' }) +export class PushNotificationService { + private readonly swPush = inject(SwPush); + private readonly http = inject(HttpClient); + private readonly api = inject(ApiConfigService); + private readonly router = inject(Router); + + /** Emits the current subscription (or null). Lets the UI reflect enabled/disabled state. */ + readonly subscription$: Observable = this.swPush.subscription; + + constructor() { + // When a notification is clicked, bring the app to the route carried in its data.url. + if (this.swPush.isEnabled) { + this.swPush.notificationClicks.subscribe(({ notification }) => { + const url = notification.data?.['url'] as string | undefined; + if (url) { + this.router.navigateByUrl(url); + } + }); + } + } + + /** + * True when the service worker is active and the browser supports push. False in dev (the ngsw + * worker is disabled there) and on browsers/contexts without push — the UI should explain why. + */ + get isSupported(): boolean { + return this.swPush.isEnabled && 'PushManager' in window; + } + + /** Whether the browser has already granted notification permission. */ + get permission(): NotificationPermission { + return typeof Notification !== 'undefined' ? Notification.permission : 'denied'; + } + + /** Subscribe this device and store the subscription on the server. */ + async enable(): Promise { + if (!this.isSupported) { + throw new Error('此瀏覽器或環境不支援推播(開發模式下 service worker 為停用)。'); + } + + const { publicKey } = await firstValueFrom( + this.http.get(this.api.getApiUrl('push/vapid-public-key')), + ); + if (!publicKey) { + throw new Error('伺服器尚未設定推播金鑰 (VAPID)。'); + } + + const subscription = await this.swPush.requestSubscription({ serverPublicKey: publicKey }); + const json = subscription.toJSON(); + await firstValueFrom( + this.http.post(this.api.getApiUrl('push/subscriptions'), { + endpoint: json.endpoint, + keys: { p256dh: json.keys?.['p256dh'], auth: json.keys?.['auth'] }, + userAgent: navigator.userAgent, + }), + ); + } + + /** Unsubscribe this device and remove the subscription from the server. */ + async disable(): Promise { + const subscription = await firstValueFrom(this.swPush.subscription); + const endpoint = subscription?.endpoint; + if (subscription) { + await this.swPush.unsubscribe(); + } + if (endpoint) { + await firstValueFrom( + this.http.request('delete', this.api.getApiUrl('push/subscriptions'), { body: { endpoint } }), + ); + } + } + + /** Ask the server to push a test notification to this member's devices. */ + async sendTestToSelf(): Promise { + await firstValueFrom(this.http.post(this.api.getApiUrl('push/test'), {})); + } +} diff --git a/APP/src/app/features/account/components/push-notifications-card/push-notifications-card.component.ts b/APP/src/app/features/account/components/push-notifications-card/push-notifications-card.component.ts new file mode 100644 index 0000000..f7ed431 --- /dev/null +++ b/APP/src/app/features/account/components/push-notifications-card/push-notifications-card.component.ts @@ -0,0 +1,109 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { PushNotificationService } from '../../../../core/services/push-notification.service'; +import { ToastService } from '../../../../core/services/toast.service'; + +/** + * Lets the logged-in member turn Web Push on/off for the current device and fire a test + * notification. Shows an iOS install hint, since on iPhone push only works once the PWA is + * added to the Home Screen. + */ +@Component({ + selector: 'app-push-notifications-card', + standalone: true, + imports: [CommonModule, ButtonsModule], + template: ` +
+

+ Get notified on this device. You can turn it off anytime. +

+ + +
+ 在 iPhone / iPad 上,請先用 Safari 的「分享 → 加入主畫面」安裝本 App,再從主畫面圖示開啟,才能啟用推播。 +
+ +
+ 此瀏覽器或環境不支援推播通知(開發模式下會停用)。 +
+ +
+ + + + ● 已啟用 Enabled on this device +
+
+ `, +}) +export class PushNotificationsCardComponent implements OnInit { + private readonly push = inject(PushNotificationService); + private readonly toast = inject(ToastService); + + supported = false; + enabled = false; + busy = false; + showIosHint = false; + + ngOnInit(): void { + this.supported = this.push.isSupported; + this.showIosHint = this.isIosNotInstalled(); + this.push.subscription$.subscribe((subscription) => (this.enabled = !!subscription)); + } + + async enable(): Promise { + this.busy = true; + try { + await this.push.enable(); + this.toast.success('已啟用推播通知。Notifications enabled.'); + } catch (err: unknown) { + this.toast.error(err instanceof Error ? err.message : '無法啟用推播通知。'); + } finally { + this.busy = false; + } + } + + async disable(): Promise { + this.busy = true; + try { + await this.push.disable(); + this.toast.success('已關閉推播通知。Notifications disabled.'); + } catch { + this.toast.error('無法關閉推播通知。'); + } finally { + this.busy = false; + } + } + + async sendTest(): Promise { + this.busy = true; + try { + await this.push.sendTestToSelf(); + this.toast.success('測試通知已送出。Test notification sent.'); + } catch { + this.toast.error('無法送出測試通知。'); + } finally { + this.busy = false; + } + } + + // iOS Safari, not running as an installed PWA — push is unavailable until added to Home Screen. + private isIosNotInstalled(): boolean { + const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent); + const isStandalone = + window.matchMedia('(display-mode: standalone)').matches || + (navigator as unknown as { standalone?: boolean }).standalone === true; + return isIos && !isStandalone; + } +} diff --git a/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html index cb05966..9afc22c 100644 --- a/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html +++ b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.html @@ -1,4 +1,4 @@ -
+

Change Password

@@ -6,4 +6,9 @@

+ +
+

Push Notifications

+ +
diff --git a/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts index 062ae15..88ac48c 100644 --- a/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts +++ b/APP/src/app/features/account/pages/account-settings-page/account-settings-page.component.ts @@ -1,11 +1,12 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ChangePasswordFormComponent } from '../../components/change-password-form/change-password-form.component'; +import { PushNotificationsCardComponent } from '../../components/push-notifications-card/push-notifications-card.component'; @Component({ selector: 'app-account-settings-page', standalone: true, - imports: [CommonModule, ChangePasswordFormComponent], + imports: [CommonModule, ChangePasswordFormComponent, PushNotificationsCardComponent], templateUrl: './account-settings-page.component.html', }) export class AccountSettingsPageComponent {} diff --git a/APP/src/app/features/members/components/send-push-dialog/send-push-dialog.component.ts b/APP/src/app/features/members/components/send-push-dialog/send-push-dialog.component.ts new file mode 100644 index 0000000..30added --- /dev/null +++ b/APP/src/app/features/members/components/send-push-dialog/send-push-dialog.component.ts @@ -0,0 +1,110 @@ +import { Component, Input, Output, EventEmitter, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { DialogsModule } from '@progress/kendo-angular-dialog'; +import { InputsModule } from '@progress/kendo-angular-inputs'; +import { LabelModule } from '@progress/kendo-angular-label'; +import { ButtonsModule } from '@progress/kendo-angular-buttons'; +import { MemberListItemDto, memberDisplayName } from '../../models/member.model'; +import { ApiConfigService } from '../../../../core/services/api-config.service'; +import { ToastService } from '../../../../core/services/toast.service'; + +interface NotificationResult { + sentCount: number; + failedCount: number; +} + +/** + * Admin test action: compose a Web Push message and send it to a single member's devices. Used to + * verify one-to-one push delivery. Reports clearly when the member has no active subscription. + */ +@Component({ + selector: 'app-send-push-dialog', + standalone: true, + imports: [ + CommonModule, ReactiveFormsModule, DialogsModule, InputsModule, LabelModule, ButtonsModule, + ], + template: ` + +

+ Send a push notification to {{ memberName }}. + They must have enabled notifications on a device first. +

+ +
+ + + + Required. + + + + + + Required. + +
+ + + + + +
+ `, +}) +export class SendPushDialogComponent implements OnInit { + @Input({ required: true }) member!: MemberListItemDto; + @Output() cancelled = new EventEmitter(); + + private readonly fb = inject(FormBuilder); + private readonly http = inject(HttpClient); + private readonly api = inject(ApiConfigService); + private readonly toast = inject(ToastService); + + form!: FormGroup; + sending = false; + + get memberName(): string { return memberDisplayName(this.member); } + + ngOnInit(): void { + this.form = this.fb.group({ + title: ['River of Life', [Validators.required]], + body: ['', [Validators.required]], + }); + } + + async send(): Promise { + if (this.form.invalid) { this.form.markAllAsTouched(); return; } + + this.sending = true; + try { + const result = await firstValueFrom( + this.http.post(this.api.getApiUrl('notifications/send-webpush'), { + memberIds: [this.member.id], + title: this.form.value.title, + body: this.form.value.body, + }), + ); + + if (result.sentCount > 0) { + this.toast.success(`已送出 ${result.sentCount} 則推播給 ${this.memberName}。`); + this.onClose(); + } else { + this.toast.error(`${this.memberName} 尚未在任何裝置啟用推播,無法送達。`); + } + } catch (err: unknown) { + const message = (err as { error?: { message?: string } })?.error?.message; + this.toast.error(message ?? '送出推播失敗。'); + } finally { + this.sending = false; + } + } + + onClose(): void { + this.cancelled.emit(); + } +} diff --git a/APP/src/app/features/members/pages/members-page/members-page.component.html b/APP/src/app/features/members/pages/members-page/members-page.component.html index b68d4e0..8f2f011 100644 --- a/APP/src/app/features/members/pages/members-page/members-page.component.html +++ b/APP/src/app/features/members/pages/members-page/members-page.component.html @@ -63,7 +63,7 @@ - +
@@ -72,6 +72,8 @@ (click)="openCreateUserDialog(row)">+ Account +
@@ -101,3 +103,10 @@ [member]="selectedMemberForInvite" (cancelled)="closeInviteDialog()"> + + + + diff --git a/APP/src/app/features/members/pages/members-page/members-page.component.ts b/APP/src/app/features/members/pages/members-page/members-page.component.ts index 28d23f1..0d710fd 100644 --- a/APP/src/app/features/members/pages/members-page/members-page.component.ts +++ b/APP/src/app/features/members/pages/members-page/members-page.component.ts @@ -10,6 +10,7 @@ import { MemberApiService } from '../../services/member-api.service'; import { MemberFormDialogComponent } from '../../components/member-form-dialog/member-form-dialog.component'; import { CreateUserDialogComponent } from '../../components/create-user-dialog/create-user-dialog.component'; import { InvitationDialogComponent } from '../../components/invitation-dialog/invitation-dialog.component'; +import { SendPushDialogComponent } from '../../components/send-push-dialog/send-push-dialog.component'; import { MemberListItemDto, MemberDto, CreateMemberRequest, PagedResult, memberDisplayName @@ -25,6 +26,7 @@ import { HasPermissionDirective } from '../../../../core/directives/has-permissi CommonModule, FormsModule, GridModule, InputsModule, ButtonsModule, IndicatorsModule, DropDownsModule, MemberFormDialogComponent, CreateUserDialogComponent, InvitationDialogComponent, + SendPushDialogComponent, PageHeaderActionsDirective, HasPermissionDirective, ], templateUrl: './members-page.component.html', @@ -50,9 +52,11 @@ export class MembersPageComponent implements OnInit { showMemberDialog = false; showCreateUserDialog = false; showInviteDialog = false; + showPushDialog = false; editingMember: MemberDto | null = null; selectedMemberForUser: MemberListItemDto | null = null; selectedMemberForInvite: MemberListItemDto | null = null; + selectedMemberForPush: MemberListItemDto | null = null; readonly memberDisplayName = memberDisplayName; @@ -158,4 +162,16 @@ export class MembersPageComponent implements OnInit { // An invitation may have just created an account, so refresh the grid. this.loadData(); } + + // ── Send Push (one-to-one test) ────────────────────────────────────────────── + + openPushDialog(member: MemberListItemDto): void { + this.selectedMemberForPush = member; + this.showPushDialog = true; + } + + closePushDialog(): void { + this.showPushDialog = false; + this.selectedMemberForPush = null; + } }