diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DeviceTokenServiceTests.cs index ec1a435d..15ba5786 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.Test/PushNotificationServiceTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs index 47fca3b1..2499e494 100644 --- a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs +++ b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PushNotificationServiceTests.cs @@ -1,5 +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; @@ -29,10 +33,87 @@ 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"); } + + [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")); + } + + // 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 = CreateService(); + + 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 = CreateService(); + + 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 PushNotificationService CreateService() => + new(TimePlanningPnDbContext!, Substitute.For>()); + + 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.Test/SettingsServiceExtendedTests.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/SettingsServiceExtendedTests.cs index d50a660a..d860757c 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 0fd9607c..89cc46e9 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 5fa4d6c9..5efa9e97 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/Protos/device_token.proto b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Protos/device_token.proto index c43e18a5..0ef28022 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/Services/DeviceTokenService/DeviceTokenService.cs b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/DeviceTokenService/DeviceTokenService.cs index 279e02fb..ad07302e 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 4b36af79..38206f22 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 7d3b528c..70c7e755 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 { 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 3e9e31f4..7a9e1d51 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 ab7501e9..7004e101 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 { @@ -57,11 +58,65 @@ 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; + } + + /// + /// 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) { @@ -73,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) { @@ -87,16 +140,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); } 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 d15ba743..7bec78d5 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,9 +54,15 @@ public class TimeSettingService( IUserService userService, ITimePlanningLocalizationService localizationService, BaseDbContext baseDbContext, - IEFormCoreService core) + IEFormCoreService core, + 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 @@ -1077,6 +1084,25 @@ 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" } }, + minBuild: MinSettingsChangedPushBuild); + } + 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) diff --git a/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj b/eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/TimePlanning.Pn.csproj index aef39ee0..f2218b80 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 @@ - +