support PWA notification.
ci-cd-vm / ci-cd (push) Failing after 1m34s

This commit is contained in:
Chris Chen
2026-06-29 22:20:15 -07:00
parent 45d910b554
commit b9210f2501
32 changed files with 1054 additions and 12 deletions
@@ -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);
+13
View File
@@ -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.
+14
View File
@@ -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")
+4
View File
@@ -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
+1
View File
@@ -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];
}