@@ -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() { }
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> SentToEndpoints { get; } = new();
|
||||
public string? ExpireForEndpoint { get; set; }
|
||||
public string? FailForEndpoint { get; set; }
|
||||
|
||||
public Task<WebPushSendResult> 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<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;
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<IActionResult> SendWebPush([FromBody] SendWebPushRequest request, CancellationToken ct)
|
||||
=> Ok(await _webPush.SendToMembersAsync(
|
||||
request.MemberIds ?? [],
|
||||
new WebPushPayload(request.Title, request.Body, request.Url),
|
||||
_currentUser.UserIdOrSystem, ct));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<IActionResult> 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<IActionResult> 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<IActionResult> 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<int?> 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>Admin manual Web Push send to one or more members (used by the Member Management test action).</summary>
|
||||
public sealed record SendWebPushRequest(int[]? MemberIds, string Title, string Body, string? Url);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ROLAC.API.DTOs.Push;
|
||||
|
||||
/// <summary>The PushSubscription a browser produces, posted when a member enables notifications.</summary>
|
||||
public sealed record PushSubscriptionRequest(string Endpoint, PushSubscriptionKeys Keys, string? UserAgent);
|
||||
|
||||
public sealed record PushSubscriptionKeys(string P256dh, string Auth);
|
||||
|
||||
/// <summary>Identifies the subscription to remove when a member disables notifications.</summary>
|
||||
public sealed record PushUnsubscribeRequest(string Endpoint);
|
||||
@@ -38,6 +38,7 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
public DbSet<LineBindingCode> LineBindingCodes => Set<LineBindingCode>();
|
||||
public DbSet<MessagingGroup> MessagingGroups => Set<MessagingGroup>();
|
||||
public DbSet<NotificationLog> NotificationLogs => Set<NotificationLog>();
|
||||
public DbSet<WebPushSubscription> WebPushSubscriptions => Set<WebPushSubscription>();
|
||||
|
||||
public DbSet<SiteSetting> SiteSettings => Set<SiteSetting>();
|
||||
public DbSet<NotificationSetting> NotificationSettings => Set<NotificationSetting>();
|
||||
@@ -560,6 +561,18 @@ public class AppDbContext : IdentityDbContext<AppUser, AppRole, string>
|
||||
.HasForeignKey(e => e.MessagingGroupId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<WebPushSubscription>(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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using ROLAC.API.Entities;
|
||||
|
||||
namespace ROLAC.API.Entities.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// A browser/device Web Push subscription owned by a member. Unlike <see cref="MemberChannelBinding"/>
|
||||
/// (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.
|
||||
/// </summary>
|
||||
public class WebPushSubscription
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int MemberId { get; set; }
|
||||
public Member? Member { get; set; }
|
||||
|
||||
/// <summary>The push service endpoint URL (unique per subscription).</summary>
|
||||
public string Endpoint { get; set; } = null!;
|
||||
|
||||
/// <summary>The subscription's P-256 ECDH public key (base64url).</summary>
|
||||
public string P256dh { get; set; } = null!;
|
||||
|
||||
/// <summary>The subscription's auth secret (base64url).</summary>
|
||||
public string Auth { get; set; } = null!;
|
||||
|
||||
/// <summary>The originating user agent, kept for display/debugging.</summary>
|
||||
public string? UserAgent { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>Last time a push to this subscription succeeded.</summary>
|
||||
public DateTime? LastUsedAt { get; set; }
|
||||
}
|
||||
@@ -1704,6 +1704,9 @@ namespace ROLAC.API.Migrations
|
||||
b.Property<bool>("EnableLine")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("EnableWebPush")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("FromAddress")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
@@ -1753,6 +1756,18 @@ namespace ROLAC.API.Migrations
|
||||
.HasMaxLength(450)
|
||||
.HasColumnType("character varying(450)");
|
||||
|
||||
b.Property<string>("VapidPrivateKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("VapidPublicKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("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<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("Auth")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Endpoint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)");
|
||||
|
||||
b.Property<DateTime?>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MemberId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("P256dh")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<string>("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<int>("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")
|
||||
|
||||
@@ -188,6 +188,10 @@ builder.Services.AddScoped<ROLAC.API.Services.Notifications.ILineNotificationSer
|
||||
ROLAC.API.Services.Notifications.LineNotificationService>();
|
||||
builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
|
||||
ROLAC.API.Services.Notifications.LineMessageChannel>();
|
||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IWebPushSender,
|
||||
ROLAC.API.Services.Notifications.WebPushSender>();
|
||||
builder.Services.AddScoped<ROLAC.API.Services.Notifications.IWebPushService,
|
||||
ROLAC.API.Services.Notifications.WebPushService>();
|
||||
|
||||
// ── AI assist (expense translation + category suggestion) ──────────────────
|
||||
// Backend proxy so the API key stays server-side. Provider + model + key come from the
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
<PackageReference Include="WebPush" Version="1.0.12" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>The three fields a browser hands over when it creates a push subscription.</summary>
|
||||
public sealed record WebPushTarget(string Endpoint, string P256dh, string Auth);
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of pushing to one subscription. <see cref="IsExpired"/> is true when the push service
|
||||
/// reported the subscription is gone (HTTP 404/410) so the caller can prune it.
|
||||
/// </summary>
|
||||
public sealed record WebPushSendResult(bool Success, bool IsExpired, string? Error);
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper over the WebPush library's encrypt-and-POST so that <see cref="WebPushService"/>'s
|
||||
/// recipient resolution, pruning, and logging can be unit-tested without a real push service.
|
||||
/// </summary>
|
||||
public interface IWebPushSender
|
||||
{
|
||||
Task<WebPushSendResult> SendAsync(WebPushTarget target, string payloadJson, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// The Web Push channel — peer to <see cref="IEmailService"/> and <see cref="ILineNotificationService"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IWebPushService
|
||||
{
|
||||
Task<NotificationResult> SendToMembersAsync(
|
||||
IReadOnlyCollection<int> memberIds, WebPushPayload payload,
|
||||
string sentByUserId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ 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";
|
||||
public const string Email = "email";
|
||||
public const string Line = "line";
|
||||
public const string WebPush = "webpush";
|
||||
}
|
||||
|
||||
/// <summary>Canonical target-type discriminators stored in NotificationLog.TargetType.</summary>
|
||||
@@ -36,6 +37,12 @@ public sealed record NotificationResult(
|
||||
/// <summary>A file attached to an outbound email.</summary>
|
||||
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
|
||||
|
||||
/// <summary>
|
||||
/// The content of a single Web Push notification. <see cref="Url"/> is the in-app path to open when
|
||||
/// the notification is clicked; <see cref="Tag"/> lets a newer notification replace an older one.
|
||||
/// </summary>
|
||||
public sealed record WebPushPayload(string Title, string Body, string? Url = null, string? Tag = null);
|
||||
|
||||
/// <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.
|
||||
|
||||
@@ -18,3 +18,11 @@ public sealed class LineOptions
|
||||
public string ChannelAccessToken { get; set; } = "";
|
||||
public string ChannelSecret { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>VAPID settings for Web Push. Sourced from the NotificationSetting row at runtime.</summary>
|
||||
public sealed class WebPushOptions
|
||||
{
|
||||
public string PublicKey { get; set; } = "";
|
||||
public string PrivateKey { get; set; } = "";
|
||||
public string Subject { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Net;
|
||||
using WebPush;
|
||||
|
||||
namespace ROLAC.API.Services.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="WebPushSendResult.IsExpired"/> so the caller prunes the dead subscription.
|
||||
/// </summary>
|
||||
public sealed class WebPushSender : IWebPushSender
|
||||
{
|
||||
private readonly INotificationSettingsService _settings;
|
||||
private readonly WebPushClient _client = new();
|
||||
|
||||
public WebPushSender(INotificationSettingsService settings) => _settings = settings;
|
||||
|
||||
public async Task<WebPushSendResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the target members' device subscriptions, pushes one notification to each via the
|
||||
/// <see cref="IWebPushSender"/>, 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 <see cref="EmailService"/>).
|
||||
/// </summary>
|
||||
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<NotificationResult> SendToMembersAsync(
|
||||
IReadOnlyCollection<int> 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<NotificationFailure>();
|
||||
var expired = new List<WebPushSubscription>();
|
||||
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<string, object?>
|
||||
{
|
||||
["title"] = payload.Title,
|
||||
["body"] = payload.Body,
|
||||
["icon"] = "assets/AppLogo-192.png",
|
||||
["data"] = new Dictionary<string, object?>
|
||||
{
|
||||
["url"] = payload.Url,
|
||||
["tag"] = payload.Tag,
|
||||
},
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(payload.Tag)) notification["tag"] = payload.Tag;
|
||||
|
||||
return JsonSerializer.Serialize(new Dictionary<string, object?> { ["notification"] = notification });
|
||||
}
|
||||
|
||||
private static string TruncateEndpoint(string endpoint) =>
|
||||
endpoint.Length <= TargetExternalIdMaxLength ? endpoint : endpoint[..TargetExternalIdMaxLength];
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
"@angular/localize/init"
|
||||
],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"serviceWorker": "ngsw-config.json",
|
||||
"assets": [
|
||||
"src/assets",
|
||||
"src/manifest.webmanifest"
|
||||
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+22
-2
@@ -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",
|
||||
|
||||
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
]
|
||||
};
|
||||
|
||||
@@ -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<PushSubscription | null> = 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<void> {
|
||||
if (!this.isSupported) {
|
||||
throw new Error('此瀏覽器或環境不支援推播(開發模式下 service worker 為停用)。');
|
||||
}
|
||||
|
||||
const { publicKey } = await firstValueFrom(
|
||||
this.http.get<VapidPublicKeyResponse>(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<void> {
|
||||
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<void> {
|
||||
await firstValueFrom(this.http.post(this.api.getApiUrl('push/test'), {}));
|
||||
}
|
||||
}
|
||||
+109
@@ -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: `
|
||||
<div class="flex flex-col gap-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Get notified on this device. You can turn it off anytime.
|
||||
</p>
|
||||
|
||||
<!-- iOS: must install to Home Screen first -->
|
||||
<div *ngIf="showIosHint" class="rounded-md bg-amber-50 border border-amber-200 p-3 text-sm text-amber-800">
|
||||
在 iPhone / iPad 上,請先用 Safari 的「分享 → 加入主畫面」安裝本 App,再從主畫面圖示開啟,才能啟用推播。
|
||||
</div>
|
||||
|
||||
<div *ngIf="!supported && !showIosHint"
|
||||
class="rounded-md bg-gray-50 border border-gray-200 p-3 text-sm text-gray-600">
|
||||
此瀏覽器或環境不支援推播通知(開發模式下會停用)。
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button *ngIf="!enabled" kendoButton themeColor="primary"
|
||||
[disabled]="!supported || busy" (click)="enable()">
|
||||
Enable notifications
|
||||
</button>
|
||||
<button *ngIf="enabled" kendoButton
|
||||
[disabled]="busy" (click)="disable()">
|
||||
Disable notifications
|
||||
</button>
|
||||
<button *ngIf="enabled" kendoButton themeColor="info"
|
||||
[disabled]="busy" (click)="sendTest()">
|
||||
Send test
|
||||
</button>
|
||||
<span *ngIf="enabled" class="text-sm text-green-700">● 已啟用 Enabled on this device</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
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<void> {
|
||||
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<void> {
|
||||
this.busy = true;
|
||||
try {
|
||||
await this.push.disable();
|
||||
this.toast.success('已關閉推播通知。Notifications disabled.');
|
||||
} catch {
|
||||
this.toast.error('無法關閉推播通知。');
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendTest(): Promise<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
<div class="p-4 md:p-6">
|
||||
<div class="p-4 md:p-6 flex flex-col gap-4">
|
||||
<section class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 max-w-xl">
|
||||
<h2 class="text-lg font-semibold mb-1">Change Password</h2>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
@@ -6,4 +6,9 @@
|
||||
</p>
|
||||
<app-change-password-form></app-change-password-form>
|
||||
</section>
|
||||
|
||||
<section class="bg-white rounded-lg shadow-sm border border-gray-200 p-4 md:p-6 max-w-xl">
|
||||
<h2 class="text-lg font-semibold mb-1">Push Notifications</h2>
|
||||
<app-push-notifications-card></app-push-notifications-card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+2
-1
@@ -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 {}
|
||||
|
||||
+110
@@ -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: `
|
||||
<kendo-dialog title="Send Push Notification" (close)="onClose()" [width]="520" [maxWidth]="'95vw'">
|
||||
<p class="k-mb-3">
|
||||
Send a push notification to <strong>{{ memberName }}</strong>.
|
||||
They must have enabled notifications on a device first.
|
||||
</p>
|
||||
|
||||
<form [formGroup]="form" class="k-form k-form-vertical">
|
||||
<kendo-formfield>
|
||||
<kendo-label text="Title *"></kendo-label>
|
||||
<kendo-textbox formControlName="title" [maxlength]="100"></kendo-textbox>
|
||||
<kendo-formerror *ngIf="form.get('title')?.errors?.['required']">Required.</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
|
||||
<kendo-formfield>
|
||||
<kendo-label text="Message *"></kendo-label>
|
||||
<kendo-textarea formControlName="body" [rows]="3" resizable="vertical"></kendo-textarea>
|
||||
<kendo-formerror *ngIf="form.get('body')?.errors?.['required']">Required.</kendo-formerror>
|
||||
</kendo-formfield>
|
||||
</form>
|
||||
|
||||
<kendo-dialog-actions>
|
||||
<button kendoButton (click)="onClose()">Cancel</button>
|
||||
<button kendoButton themeColor="primary" [disabled]="form.invalid || sending" (click)="send()">
|
||||
Send
|
||||
</button>
|
||||
</kendo-dialog-actions>
|
||||
</kendo-dialog>
|
||||
`,
|
||||
})
|
||||
export class SendPushDialogComponent implements OnInit {
|
||||
@Input({ required: true }) member!: MemberListItemDto;
|
||||
@Output() cancelled = new EventEmitter<void>();
|
||||
|
||||
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<void> {
|
||||
if (this.form.invalid) { this.form.markAllAsTouched(); return; }
|
||||
|
||||
this.sending = true;
|
||||
try {
|
||||
const result = await firstValueFrom(
|
||||
this.http.post<NotificationResult>(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();
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
|
||||
<kendo-grid-column title="Actions" [width]="290">
|
||||
<kendo-grid-column title="Actions" [width]="360">
|
||||
<ng-template kendoGridCellTemplate let-row>
|
||||
<div class="k-d-flex k-gap-2">
|
||||
<button kendoButton size="small" (click)="openEditDialog(row)">Edit</button>
|
||||
@@ -72,6 +72,8 @@
|
||||
(click)="openCreateUserDialog(row)">+ Account</button>
|
||||
<button *appHasPermission="['Users', 'write']" kendoButton size="small" themeColor="warning"
|
||||
(click)="openInviteDialog(row)">Invite</button>
|
||||
<button *appHasPermission="['Settings', 'write']" kendoButton size="small"
|
||||
(click)="openPushDialog(row)">Push</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</kendo-grid-column>
|
||||
@@ -101,3 +103,10 @@
|
||||
[member]="selectedMemberForInvite"
|
||||
(cancelled)="closeInviteDialog()">
|
||||
</app-invitation-dialog>
|
||||
|
||||
<!-- Send Push Dialog (one-to-one test) -->
|
||||
<app-send-push-dialog
|
||||
*ngIf="showPushDialog && selectedMemberForPush"
|
||||
[member]="selectedMemberForPush"
|
||||
(cancelled)="closePushDialog()">
|
||||
</app-send-push-dialog>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user