From 9f6fb388d4ed61d638881ace24ceb728e80ba0c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 25 Aug 2026 09:44:58 +0200 Subject: [PATCH 1/6] feat(push): support data-only silent FCM messages via BuildMessage Co-Authored-By: Claude Opus 4.8 --- .../PushNotificationServiceTests.cs | 29 +++++++++++ .../PushNotificationService.cs | 51 +++++++++++++++---- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs index 47fca3b1e..4b64f2fe3 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using System.Threading.Tasks; +using FirebaseAdmin.Messaging; using Microsoft.Extensions.Logging; using NSubstitute; using NUnit.Framework; @@ -35,4 +37,31 @@ public async Task SendToSiteAsync_WhenFirebaseNotConfigured_IsNoOp() await service.SendToSiteAsync(1, "Title", "Body"); } + + [Test] + public void BuildMessage_DataOnly_OmitsNotificationAndSetsContentAvailable() + { + var msg = PushNotificationService.BuildMessage( + "tok", + "", + "", + new Dictionary { { "type", "settings_changed" } }); + + Assert.That(msg.Notification, Is.Null, + "a data-only push must not attach a visible Notification block"); + Assert.That(msg.Apns, Is.Not.Null); + Assert.That(msg.Apns.Aps.ContentAvailable, Is.True, + "iOS needs content-available to wake the app for a silent data push"); + Assert.That(msg.Data["type"], Is.EqualTo("settings_changed")); + } + + [Test] + public void BuildMessage_WithTitleOrBody_SetsNotificationBlock() + { + var msg = PushNotificationService.BuildMessage("tok", "Hello", "World", null); + + Assert.That(msg.Notification, Is.Not.Null); + Assert.That(msg.Notification.Title, Is.EqualTo("Hello")); + Assert.That(msg.Notification.Body, Is.EqualTo("World")); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs index ab7501e96..3ee69ab89 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs @@ -57,6 +57,46 @@ public PushNotificationService( } } + /// + /// Builds an FCM message. When both and + /// are empty the message is data-only (silent): no + /// visible block is attached and APNs + /// content-available is set so iOS wakes the app in the background to + /// process the data payload. Otherwise a normal visible notification is + /// attached alongside the data. + /// + public static Message BuildMessage( + string token, + string title, + string body, + Dictionary? data) + { + var hasNotification = !string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(body); + var message = new Message + { + Token = token, + Data = data + }; + + if (hasNotification) + { + message.Notification = new Notification + { + Title = title, + Body = body + }; + } + else + { + message.Apns = new ApnsConfig + { + Aps = new Aps { ContentAvailable = true } + }; + } + + return message; + } + public async Task SendToSiteAsync( int targetSdkSiteId, string title, @@ -87,16 +127,7 @@ public async Task SendToSiteAsync( { try { - var message = new Message - { - Token = deviceToken.Token, - Notification = new Notification - { - Title = title, - Body = body - }, - Data = data - }; + var message = BuildMessage(deviceToken.Token, title, body, data); await FirebaseMessaging.DefaultInstance.SendAsync(message); } From fdd3a691a811422510b65f2702620b06b5457175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 25 Aug 2026 09:47:57 +0200 Subject: [PATCH 2/6] feat(settings): fire fire-and-forget settings_changed push on UpdateAssignedSite Co-Authored-By: Claude Opus 4.8 --- .../SettingsServiceExtendedTests.cs | 3 +- .../SettingsServicePhoneNumberTests.cs | 3 +- .../SettingsServiceTests.cs | 43 ++++++++++++++++++- .../TimeSettingService.cs | 22 +++++++++- 4 files changed, 67 insertions(+), 4 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceExtendedTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceExtendedTests.cs index d50a660a6..d860757c3 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceExtendedTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceExtendedTests.cs @@ -57,7 +57,8 @@ public async Task SetUp() _userService, _localizationService, null, - _coreService); + _coreService, + Substitute.For()); } // --- GetAvailableSites tests --- diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs index 0fd9607c9..89cc46e9d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServicePhoneNumberTests.cs @@ -53,7 +53,8 @@ public async Task SetUp() _userService, _localizationService, null, - _coreService); + _coreService, + Substitute.For()); } [Test] diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceTests.cs index 5fa4d6c9a..5efa9e97d 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Microsoft.EntityFrameworkCore; @@ -27,6 +28,7 @@ public class SettingsServiceTests : TestBaseSetup private ITimePlanningLocalizationService _localizationService; private IEFormCoreService _coreService; private IPluginDbOptions _options; + private TimePlanning.Pn.Services.PushNotificationService.IPushNotificationService _pushNotificationService; [SetUp] public async Task SetUp() @@ -51,6 +53,9 @@ public async Task SetUp() SnapshotEnabled = "0" }); + _pushNotificationService = + Substitute.For(); + _settingsService = new TimeSettingService( _options, TimePlanningPnDbContext, @@ -58,7 +63,8 @@ public async Task SetUp() _userService, _localizationService, null, - _coreService); + _coreService, + _pushNotificationService); } [Test] @@ -194,6 +200,41 @@ public async Task UpdateAssignedSite_UpdatesGpsEnabled_Successfully() Assert.That(updatedSite.SnapshotEnabled, Is.False); } + [Test] + public async Task UpdateAssignedSite_StillSucceeds_WhenPushThrows() + { + // The settings_changed push is fire-and-forget: a push failure must + // never fail the settings update. + _pushNotificationService + .SendToSiteAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromException(new Exception("boom"))); + + var assignedSite = new AssignedSiteEntity + { + SiteId = 42, + UseGoogleSheetAsDefault = true, + CreatedByUserId = 1, + UpdatedByUserId = 1 + }; + await assignedSite.Create(TimePlanningPnDbContext); + + var updateModel = new AssignedSiteModel + { + Id = assignedSite.Id, + SiteId = 42, + UseGoogleSheetAsDefault = true + }; + + var result = await _settingsService.UpdateAssignedSite(updateModel); + + Assert.That(result.Success, Is.True, + "a push failure must not fail the settings update"); + } + [Test] public async Task UpdateAssignedSite_UpdatesGlobalGpsEnabled_Successfully() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs index d15ba743e..ceef20910 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs @@ -45,6 +45,7 @@ namespace TimePlanning.Pn.Services.TimePlanningSettingService; using Microting.TimePlanningBase.Infrastructure.Data; using Microting.TimePlanningBase.Infrastructure.Data.Entities; using TimePlanningLocalizationService; +using TimePlanning.Pn.Services.PushNotificationService; public class TimeSettingService( IPluginDbOptions options, @@ -53,7 +54,8 @@ public class TimeSettingService( IUserService userService, ITimePlanningLocalizationService localizationService, BaseDbContext baseDbContext, - IEFormCoreService core) + IEFormCoreService core, + IPushNotificationService pushNotificationService) : ISettingService { public async Task> GetSettings() @@ -1077,6 +1079,24 @@ public async Task UpdateAssignedSite(Infrastructure.Models.Sett await dbAssignedSite.Update(dbContext); + // Fire-and-forget: tell the worker's device(s) that their assigned-site + // settings changed so personal mode can auto-refresh. Sent AFTER the row + // is committed; a push failure must NEVER fail the settings update. + try + { + await pushNotificationService.SendToSiteAsync( + dbAssignedSite.SiteId, + title: "", + body: "", + data: new Dictionary { { "type", "settings_changed" } }); + } + catch (Exception ex) + { + logger.LogError(ex, + "Failed to send settings_changed push for SdkSiteId {SdkSiteId}", + dbAssignedSite.SiteId); + } + // Update managing tags var existingManagingTags = await dbContext.AssignedSiteManagingTags .Where(x => x.AssignedSiteId == dbAssignedSite.Id) From 12d3a59f60aa6244bcc98343c636252a916f2fd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 25 Aug 2026 14:42:32 +0200 Subject: [PATCH 3/6] chore: bump TimePlanningBase to 10.0.58 + build_number proto field Bump Microting.TimePlanningBase 10.0.57 -> 10.0.58 (adds the AppBuildNumber column to DeviceToken). Repurpose device_token.proto field 3 from the long-ignored sdk_site_id to int32 build_number, matching the flutter-time client so the wire stays identical. The C# gRPC stub regenerates at build via the existing item. Co-Authored-By: Claude Opus 4.8 --- .../TimePlanning.Pn/Protos/device_token.proto | 10 +++++++--- .../TimePlanning.Pn/TimePlanning.Pn.csproj | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/device_token.proto b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/device_token.proto index c43e18a5c..0ef280228 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/device_token.proto +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/device_token.proto @@ -9,9 +9,13 @@ option csharp_namespace = "TimePlanning.Pn.Grpc"; message RegisterDeviceTokenRequest { string token = 1; string platform = 2; - // Ignored since 2026-07: the server resolves the caller's site id from - // the JWT. Field retained for wire compatibility with shipped clients. - int32 sdk_site_id = 3; + // App build number reported by the client at registration. 0 = old/unknown + // client that predates this field. Used to version-gate silent pushes. + // + // Field 3 previously carried sdk_site_id, which the server ignored since + // 2026-07 (the caller's site id is resolved from the JWT). The client + // repurposed this int32 slot for build_number, so the wire stays identical. + int32 build_number = 3; } message UnregisterDeviceTokenRequest { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj index aef39ee0f..f2218b803 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj @@ -33,7 +33,7 @@ - + From 9b82f4f060da0ffc955a292255759831c11cb2bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 25 Aug 2026 14:42:42 +0200 Subject: [PATCH 4/6] feat: persist AppBuildNumber on device-token register Thread the client-reported build number from the gRPC handler through RegisterForCallerAsync/RegisterAsync and set deviceToken.AppBuildNumber on BOTH the create and the update (re-register) branches, so a re-register refreshes the stored build number. The param defaults to 0 (old/unknown client). Tests: persists build number on create, refreshes it on re-register, and stores 0 when the client omits it. Co-Authored-By: Claude Opus 4.8 --- .../DeviceTokenServiceTests.cs | 36 +++++++++++++++++++ .../DeviceTokenService/DeviceTokenService.cs | 8 +++-- .../DeviceTokenService/IDeviceTokenService.cs | 6 ++-- .../TimePlanningDeviceTokenGrpcService.cs | 7 ++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs index ec1a435dd..15ba57866 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs @@ -63,6 +63,42 @@ public async Task RegisterAsync_SameTokenTwice_UpsertsWithoutDuplicate() Assert.That(stored.Platform, Is.EqualTo("ios")); } + [Test] + public async Task RegisterAsync_NewToken_PersistsAppBuildNumber() + { + var result = await _service.RegisterAsync(42, "build-token", "android", 31221); + + Assert.That(result.Success, Is.True); + + var stored = await TimePlanningPnDbContext!.DeviceTokens.SingleAsync(); + Assert.That(stored.AppBuildNumber, Is.EqualTo(31221)); + } + + [Test] + public async Task RegisterAsync_ReRegister_UpdatesAppBuildNumber() + { + await _service.RegisterAsync(42, "build-token", "android", 31221); + + var result = await _service.RegisterAsync(42, "build-token", "android", 40000); + + Assert.That(result.Success, Is.True); + Assert.That(await TimePlanningPnDbContext!.DeviceTokens.CountAsync(), Is.EqualTo(1)); + + var stored = await TimePlanningPnDbContext.DeviceTokens.SingleAsync(); + Assert.That(stored.AppBuildNumber, Is.EqualTo(40000), + "a re-register must refresh the stored app build number"); + } + + [Test] + public async Task RegisterAsync_DefaultBuildNumber_PersistsZero() + { + await _service.RegisterAsync(42, "legacy-token", "android"); + + var stored = await TimePlanningPnDbContext!.DeviceTokens.SingleAsync(); + Assert.That(stored.AppBuildNumber, Is.EqualTo(0), + "an old client that omits the build number is stored as 0"); + } + [Test] public async Task UnregisterAsync_ExistingToken_IsRemoved() { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/DeviceTokenService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/DeviceTokenService.cs index 279e02fb4..ad07302ed 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/DeviceTokenService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/DeviceTokenService.cs @@ -35,7 +35,7 @@ public DeviceTokenService( _coreService = coreService; } - public async Task RegisterForCallerAsync(string token, string platform) + public async Task RegisterForCallerAsync(string token, string platform, int buildNumber = 0) { var sdkSiteId = await ResolveCallerSdkSiteIdAsync(); if (sdkSiteId == 0) @@ -46,7 +46,7 @@ public async Task RegisterForCallerAsync(string token, string p false, "Could not resolve an active site for the calling user"); } - return await RegisterAsync(sdkSiteId, token, platform); + return await RegisterAsync(sdkSiteId, token, platform, buildNumber); } /// @@ -80,7 +80,7 @@ private async Task ResolveCallerSdkSiteIdAsync() return worker.ResolveActiveSdkSiteId() ?? 0; } - public async Task RegisterAsync(int sdkSiteId, string token, string platform) + public async Task RegisterAsync(int sdkSiteId, string token, string platform, int buildNumber = 0) { try { @@ -91,6 +91,7 @@ public async Task RegisterAsync(int sdkSiteId, string token, st { existing.SdkSiteId = sdkSiteId; existing.Platform = platform; + existing.AppBuildNumber = buildNumber; await existing.Update(_dbContext); } else @@ -100,6 +101,7 @@ public async Task RegisterAsync(int sdkSiteId, string token, st SdkSiteId = sdkSiteId, Token = token, Platform = platform, + AppBuildNumber = buildNumber, }; await deviceToken.Create(_dbContext); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/IDeviceTokenService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/IDeviceTokenService.cs index 4b36af791..38206f220 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/IDeviceTokenService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/IDeviceTokenService.cs @@ -9,9 +9,11 @@ public interface IDeviceTokenService /// Registers a device token for the authenticated caller. The SDK site id /// is resolved server-side from the JWT (client-sent ids are ignored). /// Fails without storing anything when no active site resolves. + /// is the client's app build number + /// (0 = old/unknown), stored for push version-gating. /// - Task RegisterForCallerAsync(string token, string platform); + Task RegisterForCallerAsync(string token, string platform, int buildNumber = 0); - Task RegisterAsync(int sdkSiteId, string token, string platform); + Task RegisterAsync(int sdkSiteId, string token, string platform, int buildNumber = 0); Task UnregisterAsync(string token); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningDeviceTokenGrpcService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningDeviceTokenGrpcService.cs index 7d3b528c6..70c7e7552 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningDeviceTokenGrpcService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/GrpcServices/TimePlanningDeviceTokenGrpcService.cs @@ -21,10 +21,11 @@ public override async Task RegisterDeviceToken( { try { - // request.SdkSiteId is deliberately ignored: the site id is - // resolved server-side from the JWT (see DeviceTokenService). + // The site id is resolved server-side from the JWT (see + // DeviceTokenService); the client no longer sends one. The client's + // reported app build number is persisted for push version-gating. var result = await _deviceTokenService.RegisterForCallerAsync( - request.Token, request.Platform); + request.Token, request.Platform, request.BuildNumber); return new OperationResponse { From e062bd484382e3eac7482bd365fcb7dc12dc908c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 25 Aug 2026 14:42:52 +0200 Subject: [PATCH 5/6] feat: version-gate the settings_changed silent push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional minBuild param to SendToSiteAsync (interface + impl) and filter DeviceTokens by AppBuildNumber >= minBuild. Default 0 includes every device, so existing callers (absence-request / content-handover notification pushes) are unaffected. UpdateAssignedSite now passes minBuild: MinSettingsChangedPushBuild (31221) — the first flutter-time build that both sends build_number and handles the settings_changed silent push. Older installs report 0 and are excluded to avoid a stray notification. The token-selection query is extracted to an internal ResolveTargetTokensAsync seam so the filter is unit-testable (Firebase is not configured in tests, so SendToSiteAsync itself short-circuits). Co-Authored-By: Claude Opus 4.8 --- .../PushNotificationServiceTests.cs | 55 +++++++++++++++++++ .../IPushNotificationService.cs | 8 ++- .../PushNotificationService.cs | 21 +++++-- .../TimeSettingService.cs | 8 ++- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs index 4b64f2fe3..b0eba8abc 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs @@ -1,7 +1,9 @@ using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FirebaseAdmin.Messaging; using Microsoft.Extensions.Logging; +using Microting.TimePlanningBase.Infrastructure.Data.Entities; using NSubstitute; using NUnit.Framework; using TimePlanning.Pn.Services.PushNotificationService; @@ -64,4 +66,57 @@ public void BuildMessage_WithTitleOrBody_SetsNotificationBlock() Assert.That(msg.Notification.Title, Is.EqualTo("Hello")); Assert.That(msg.Notification.Body, Is.EqualTo("World")); } + + // Firebase is not configured in tests, so SendToSiteAsync short-circuits + // before it queries. The token-selection query it delegates to is exercised + // directly via the internal ResolveTargetTokensAsync seam. + + [Test] + public async Task ResolveTargetTokens_WithMinBuild_ExcludesOlderBuildsAndIncludesAtOrAbove() + { + await SeedToken("old-0", sdkSiteId: 7, buildNumber: 0); + await SeedToken("old-below", sdkSiteId: 7, buildNumber: 31220); + await SeedToken("exact", sdkSiteId: 7, buildNumber: 31221); + await SeedToken("newer", sdkSiteId: 7, buildNumber: 40000); + await SeedToken("other-site", sdkSiteId: 8, buildNumber: 40000); + + var service = new PushNotificationService( + TimePlanningPnDbContext!, + Substitute.For>()); + + var tokens = await service.ResolveTargetTokensAsync(7, minBuild: 31221); + var picked = tokens.Select(t => t.Token).ToList(); + + Assert.That(picked, Is.EquivalentTo(new[] { "exact", "newer" }), + "only same-site tokens reporting build >= minBuild must be targeted"); + } + + [Test] + public async Task ResolveTargetTokens_DefaultMinBuildZero_IncludesEveryDevice() + { + await SeedToken("legacy-0", sdkSiteId: 7, buildNumber: 0); + await SeedToken("modern", sdkSiteId: 7, buildNumber: 40000); + + var service = new PushNotificationService( + TimePlanningPnDbContext!, + Substitute.For>()); + + var tokens = await service.ResolveTargetTokensAsync(7, minBuild: 0); + + Assert.That(tokens.Select(t => t.Token), + Is.EquivalentTo(new[] { "legacy-0", "modern" }), + "minBuild 0 must keep existing callers unaffected (all devices included)"); + } + + private async Task SeedToken(string token, int sdkSiteId, int buildNumber) + { + var deviceToken = new DeviceToken + { + SdkSiteId = sdkSiteId, + Token = token, + Platform = "android", + AppBuildNumber = buildNumber + }; + await deviceToken.Create(TimePlanningPnDbContext!); + } } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/IPushNotificationService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/IPushNotificationService.cs index 3e9e31f4c..7a9e1d515 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/IPushNotificationService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/IPushNotificationService.cs @@ -5,5 +5,11 @@ namespace TimePlanning.Pn.Services.PushNotificationService; public interface IPushNotificationService { - Task SendToSiteAsync(int targetSdkSiteId, string title, string body, Dictionary? data = null); + /// + /// Sends a push to every registered device of . + /// gates delivery to devices whose reported + /// AppBuildNumber is >= the value; the default of 0 includes every device + /// (including old installs that report 0), so existing callers are unaffected. + /// + Task SendToSiteAsync(int targetSdkSiteId, string title, string body, Dictionary? data = null, int minBuild = 0); } diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs index 3ee69ab89..136666d48 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs @@ -97,11 +97,26 @@ public static Message BuildMessage( return message; } + /// + /// Resolves the live device tokens targeted by a push: same site, still in + /// the Created workflow state, and reporting an app build number at or above + /// . A of 0 includes + /// every device (old installs report AppBuildNumber 0). + /// + internal Task> + ResolveTargetTokensAsync(int targetSdkSiteId, int minBuild) => + _dbContext.DeviceTokens + .Where(dt => dt.SdkSiteId == targetSdkSiteId + && dt.WorkflowState == Constants.WorkflowStates.Created + && dt.AppBuildNumber >= minBuild) + .ToListAsync(); + public async Task SendToSiteAsync( int targetSdkSiteId, string title, string body, - Dictionary? data = null) + Dictionary? data = null, + int minBuild = 0) { if (!_isEnabled) { @@ -113,9 +128,7 @@ public async Task SendToSiteAsync( try { - var tokens = await _dbContext.DeviceTokens - .Where(dt => dt.SdkSiteId == targetSdkSiteId && dt.WorkflowState == Constants.WorkflowStates.Created) - .ToListAsync(); + var tokens = await ResolveTargetTokensAsync(targetSdkSiteId, minBuild); if (tokens.Count == 0) { diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs index ceef20910..7bec78d54 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningSettingService/TimeSettingService.cs @@ -58,6 +58,11 @@ public class TimeSettingService( IPushNotificationService pushNotificationService) : ISettingService { + // First flutter-time build that sends build_number AND handles the + // settings_changed silent push; older installs report AppBuildNumber 0 and + // are excluded to avoid a stray notification. + private const int MinSettingsChangedPushBuild = 31221; + public async Task> GetSettings() { try @@ -1088,7 +1093,8 @@ await pushNotificationService.SendToSiteAsync( dbAssignedSite.SiteId, title: "", body: "", - data: new Dictionary { { "type", "settings_changed" } }); + data: new Dictionary { { "type", "settings_changed" } }, + minBuild: MinSettingsChangedPushBuild); } catch (Exception ex) { From da323a8cd25310da8bf34746a548323c57dd09cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Tue, 25 Aug 2026 14:44:42 +0200 Subject: [PATCH 6/6] refactor: tidy ResolveTargetTokensAsync seam and push-test setup Replace the fully-qualified DeviceToken return type on ResolveTargetTokensAsync with a using alias (the plain namespace import clashes with FirebaseAdmin.Messaging.Message), and factor the repeated PushNotificationService construction in the tests into a CreateService helper. No behavior change. Co-Authored-By: Claude Opus 4.8 --- .../PushNotificationServiceTests.cs | 15 ++++++--------- .../PushNotificationService.cs | 4 ++-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs index b0eba8abc..2499e494e 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs @@ -33,9 +33,7 @@ public void Constructor_WithoutFirebaseConfig_DoesNotThrow() [Test] public async Task SendToSiteAsync_WhenFirebaseNotConfigured_IsNoOp() { - var service = new PushNotificationService( - TimePlanningPnDbContext!, - Substitute.For>()); + var service = CreateService(); await service.SendToSiteAsync(1, "Title", "Body"); } @@ -80,9 +78,7 @@ public async Task ResolveTargetTokens_WithMinBuild_ExcludesOlderBuildsAndInclude await SeedToken("newer", sdkSiteId: 7, buildNumber: 40000); await SeedToken("other-site", sdkSiteId: 8, buildNumber: 40000); - var service = new PushNotificationService( - TimePlanningPnDbContext!, - Substitute.For>()); + var service = CreateService(); var tokens = await service.ResolveTargetTokensAsync(7, minBuild: 31221); var picked = tokens.Select(t => t.Token).ToList(); @@ -97,9 +93,7 @@ public async Task ResolveTargetTokens_DefaultMinBuildZero_IncludesEveryDevice() await SeedToken("legacy-0", sdkSiteId: 7, buildNumber: 0); await SeedToken("modern", sdkSiteId: 7, buildNumber: 40000); - var service = new PushNotificationService( - TimePlanningPnDbContext!, - Substitute.For>()); + var service = CreateService(); var tokens = await service.ResolveTargetTokensAsync(7, minBuild: 0); @@ -108,6 +102,9 @@ public async Task ResolveTargetTokens_DefaultMinBuildZero_IncludesEveryDevice() "minBuild 0 must keep existing callers unaffected (all devices included)"); } + private PushNotificationService CreateService() => + new(TimePlanningPnDbContext!, Substitute.For>()); + private async Task SeedToken(string token, int sdkSiteId, int buildNumber) { var deviceToken = new DeviceToken diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs index 136666d48..7004e101a 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/PushNotificationService/PushNotificationService.cs @@ -11,6 +11,7 @@ namespace TimePlanning.Pn.Services.PushNotificationService; using Microsoft.Extensions.Logging; using Microting.eForm.Infrastructure.Constants; using Microting.TimePlanningBase.Infrastructure.Data; +using DeviceToken = Microting.TimePlanningBase.Infrastructure.Data.Entities.DeviceToken; public class PushNotificationService : IPushNotificationService { @@ -103,8 +104,7 @@ public static Message BuildMessage( /// . A of 0 includes /// every device (old installs report AppBuildNumber 0). /// - internal Task> - ResolveTargetTokensAsync(int targetSdkSiteId, int minBuild) => + internal Task> ResolveTargetTokensAsync(int targetSdkSiteId, int minBuild) => _dbContext.DeviceTokens .Where(dt => dt.SdkSiteId == targetSdkSiteId && dt.WorkflowState == Constants.WorkflowStates.Created