Skip to content

Commit cf4cc18

Browse files
renemadsenclaude
andcommitted
feat: add push notification infrastructure for FCM
Add device token storage (DeviceToken entity, DbContext, REST + gRPC endpoints), Firebase Admin SDK integration with conditional initialization, and fire-and-forget push triggers in AbsenceRequestService and ContentHandoverService for create/approve/reject flows. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 78ccac1 commit cf4cc18

File tree

17 files changed

+655
-4
lines changed

17 files changed

+655
-4
lines changed

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ public async Task SetUp()
5050
_userService,
5151
_localizationService,
5252
_coreService,
53-
baseDbContext);
53+
baseDbContext,
54+
Substitute.For<TimePlanning.Pn.Services.PushNotificationService.IPushNotificationService>());
5455
}
5556

5657
[Test]

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@ public async Task SetUp()
4040
_userService,
4141
_localizationService,
4242
Substitute.For<Microting.eFormApi.BasePn.Abstractions.IEFormCoreService>(),
43-
baseDbContext);
43+
baseDbContext,
44+
Substitute.For<TimePlanning.Pn.Services.PushNotificationService.IPushNotificationService>());
4445
}
4546

4647
// GetHandoverEligibleCoworkersAsync exercises the real SDK MicrotingDbContext (Workers,
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#nullable enable
2+
namespace TimePlanning.Pn.Controllers;
3+
4+
using System.Linq;
5+
using System.Threading.Tasks;
6+
using Infrastructure.Models.DeviceToken;
7+
using Microsoft.AspNetCore.Mvc;
8+
using Microsoft.EntityFrameworkCore;
9+
using Microting.eForm.Infrastructure.Constants;
10+
using Microting.EformAngularFrontendBase.Infrastructure.Data;
11+
using Microting.eFormApi.BasePn.Abstractions;
12+
using Microting.eFormApi.BasePn.Infrastructure.Models.API;
13+
using Services.DeviceTokenService;
14+
15+
[Route("api/time-planning-pn/device-tokens")]
16+
public class DeviceTokenController : Controller
17+
{
18+
private readonly IDeviceTokenService _deviceTokenService;
19+
private readonly IUserService _userService;
20+
private readonly IEFormCoreService _coreService;
21+
private readonly BaseDbContext _baseDbContext;
22+
23+
public DeviceTokenController(
24+
IDeviceTokenService deviceTokenService,
25+
IUserService userService,
26+
IEFormCoreService coreService,
27+
BaseDbContext baseDbContext)
28+
{
29+
_deviceTokenService = deviceTokenService;
30+
_userService = userService;
31+
_coreService = coreService;
32+
_baseDbContext = baseDbContext;
33+
}
34+
35+
[HttpPost]
36+
public async Task<OperationResult> Register([FromBody] RegisterDeviceTokenModel model)
37+
{
38+
var sdkSiteId = await ResolveCallerSdkSiteIdAsync();
39+
if (sdkSiteId == 0)
40+
{
41+
return new OperationResult(false, "Could not resolve caller SdkSiteId");
42+
}
43+
44+
return await _deviceTokenService.RegisterAsync(sdkSiteId, model.Token, model.Platform);
45+
}
46+
47+
[HttpDelete]
48+
public async Task<OperationResult> Unregister([FromBody] UnregisterDeviceTokenModel model)
49+
{
50+
return await _deviceTokenService.UnregisterAsync(model.Token);
51+
}
52+
53+
private async Task<int> ResolveCallerSdkSiteIdAsync()
54+
{
55+
var currentUserAsync = await _userService.GetCurrentUserAsync();
56+
var currentUser = _baseDbContext.Users
57+
.Single(x => x.Id == currentUserAsync.Id);
58+
59+
var sdkCore = await _coreService.GetCore();
60+
var sdkDbContext = sdkCore.DbContextHelper.GetDbContext();
61+
62+
var worker = await sdkDbContext.Workers
63+
.Include(x => x.SiteWorkers)
64+
.ThenInclude(x => x.Site)
65+
.Where(x => x.WorkflowState != Constants.WorkflowStates.Removed)
66+
.FirstOrDefaultAsync(x => x.Email == currentUser.Email);
67+
68+
if (worker == null || worker.SiteWorkers.Count == 0)
69+
{
70+
return 0;
71+
}
72+
return worker.SiteWorkers.First().Site.MicrotingUid ?? 0;
73+
}
74+
}

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
3838
using TimePlanning.Pn.Services.PayTierRuleService;
3939
using TimePlanning.Pn.Services.PayTimeBandRuleService;
4040
using TimePlanning.Pn.Services.PayrollExportService;
41+
using TimePlanning.Pn.Services.DeviceTokenService;
42+
using TimePlanning.Pn.Services.PushNotificationService;
43+
using TimePlanning.Pn.Infrastructure.Models.DeviceToken;
4144
using Constants = Microting.eForm.Infrastructure.Constants.Constants;
4245

4346
namespace TimePlanning.Pn;
@@ -107,6 +110,8 @@ public void ConfigureServices(IServiceCollection services)
107110
services.AddTransient<IPayTierRuleService, PayTierRuleService>();
108111
services.AddTransient<IPayTimeBandRuleService, PayTimeBandRuleService>();
109112
services.AddTransient<IPayrollExportService, PayrollExportService>();
113+
services.AddTransient<IDeviceTokenService, DeviceTokenService>();
114+
services.AddSingleton<IPushNotificationService, PushNotificationService>();
110115
services.AddControllers();
111116
}
112117

@@ -187,6 +192,14 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr
187192
}));
188193

189194

195+
services.AddDbContext<DeviceTokenDbContext>(o =>
196+
o.UseMySql(connectionString, new MariaDbServerVersion(
197+
ServerVersion.AutoDetect(connectionString)), mySqlOptionsAction: builder =>
198+
{
199+
builder.EnableRetryOnFailure();
200+
builder.MigrationsAssembly(PluginAssembly().FullName);
201+
}));
202+
190203
services.AddDbContext<BaseDbContext>(
191204
o => o.UseMySql(frontendBaseConnectionString, new MariaDbServerVersion(
192205
ServerVersion.AutoDetect(frontendBaseConnectionString)), mySqlOptionsAction: builder =>
@@ -201,6 +214,23 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr
201214
context.Database.Migrate();
202215
Console.WriteLine("TimePlanningPnDbContext migrated to latest version");
203216

217+
// Ensure DeviceTokens table exists (raw SQL to avoid EnsureCreated conflicts)
218+
Console.WriteLine("Ensuring DeviceTokens table exists");
219+
context.Database.ExecuteSqlRaw(@"
220+
CREATE TABLE IF NOT EXISTS `DeviceTokens` (
221+
`Id` int NOT NULL AUTO_INCREMENT,
222+
`SdkSiteId` int NOT NULL,
223+
`Token` varchar(512) NOT NULL,
224+
`Platform` varchar(16) NOT NULL,
225+
`CreatedAt` datetime(6) NOT NULL,
226+
`UpdatedAt` datetime(6) NOT NULL,
227+
`WorkflowState` varchar(50) NULL DEFAULT 'created',
228+
PRIMARY KEY (`Id`),
229+
UNIQUE INDEX `IX_DeviceTokens_Token` (`Token`),
230+
INDEX `IX_DeviceTokens_SdkSiteId` (`SdkSiteId`)
231+
) CHARACTER SET=utf8mb4;
232+
");
233+
204234
// Seed database
205235
SeedDatabase(connectionString);
206236
}
@@ -216,6 +246,7 @@ public void Configure(IApplicationBuilder appBuilder)
216246
endpoints.MapGrpcService<TimePlanningPlanningsGrpcService>();
217247
endpoints.MapGrpcService<TimePlanningAbsenceRequestGrpcService>();
218248
endpoints.MapGrpcService<TimePlanningContentHandoverGrpcService>();
249+
endpoints.MapGrpcService<TimePlanningDeviceTokenGrpcService>();
219250
});
220251
}
221252

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
namespace TimePlanning.Pn.Infrastructure.Models.DeviceToken;
2+
3+
using System;
4+
using System.ComponentModel.DataAnnotations;
5+
using System.ComponentModel.DataAnnotations.Schema;
6+
7+
[Table("DeviceTokens")]
8+
public class DeviceToken
9+
{
10+
[Key]
11+
public int Id { get; set; }
12+
public int SdkSiteId { get; set; }
13+
14+
[Required]
15+
[MaxLength(512)]
16+
public string Token { get; set; } = string.Empty;
17+
18+
[Required]
19+
[MaxLength(16)]
20+
public string Platform { get; set; } = string.Empty; // "android" or "ios"
21+
22+
public DateTime CreatedAt { get; set; }
23+
public DateTime UpdatedAt { get; set; }
24+
25+
[MaxLength(50)]
26+
public string WorkflowState { get; set; } = "created";
27+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace TimePlanning.Pn.Infrastructure.Models.DeviceToken;
2+
3+
using Microsoft.EntityFrameworkCore;
4+
5+
public class DeviceTokenDbContext : DbContext
6+
{
7+
public DeviceTokenDbContext(DbContextOptions<DeviceTokenDbContext> options)
8+
: base(options)
9+
{
10+
}
11+
12+
public DbSet<DeviceToken> DeviceTokens { get; set; } = null!;
13+
14+
protected override void OnModelCreating(ModelBuilder modelBuilder)
15+
{
16+
base.OnModelCreating(modelBuilder);
17+
18+
modelBuilder.Entity<DeviceToken>(entity =>
19+
{
20+
entity.HasIndex(e => e.Token).IsUnique();
21+
entity.HasIndex(e => e.SdkSiteId);
22+
});
23+
}
24+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
namespace TimePlanning.Pn.Infrastructure.Models.DeviceToken;
2+
3+
public class RegisterDeviceTokenModel
4+
{
5+
public string Token { get; set; } = string.Empty;
6+
public string Platform { get; set; } = string.Empty;
7+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace TimePlanning.Pn.Infrastructure.Models.DeviceToken;
2+
3+
public class UnregisterDeviceTokenModel
4+
{
5+
public string Token { get; set; } = string.Empty;
6+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
syntax = "proto3";
2+
3+
package timeplanning;
4+
5+
import "common.proto";
6+
7+
option csharp_namespace = "TimePlanning.Pn.Grpc";
8+
9+
message RegisterDeviceTokenRequest {
10+
string token = 1;
11+
string platform = 2;
12+
int32 sdk_site_id = 3;
13+
}
14+
15+
message UnregisterDeviceTokenRequest {
16+
string token = 1;
17+
}
18+
19+
service TimePlanningDeviceTokenService {
20+
rpc RegisterDeviceToken(RegisterDeviceTokenRequest) returns (OperationResponse);
21+
rpc UnregisterDeviceToken(UnregisterDeviceTokenRequest) returns (OperationResponse);
22+
}

eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ namespace TimePlanning.Pn.Services.AbsenceRequestService;
4040
using Microting.eForm.Infrastructure.Constants;
4141
using Microting.eFormApi.BasePn.Infrastructure.Helpers;
4242
using Microting.EformAngularFrontendBase.Infrastructure.Data;
43+
using TimePlanning.Pn.Services.PushNotificationService;
4344
using TimePlanningLocalizationService;
4445

4546
public class AbsenceRequestService : IAbsenceRequestService
@@ -50,21 +51,24 @@ public class AbsenceRequestService : IAbsenceRequestService
5051
private readonly ITimePlanningLocalizationService _localizationService;
5152
private readonly IEFormCoreService _coreService;
5253
private readonly BaseDbContext _baseDbContext;
54+
private readonly IPushNotificationService _pushNotificationService;
5355

5456
public AbsenceRequestService(
5557
ILogger<AbsenceRequestService> logger,
5658
TimePlanningPnDbContext dbContext,
5759
IUserService userService,
5860
ITimePlanningLocalizationService localizationService,
5961
IEFormCoreService coreService,
60-
BaseDbContext baseDbContext)
62+
BaseDbContext baseDbContext,
63+
IPushNotificationService pushNotificationService)
6164
{
6265
_logger = logger;
6366
_dbContext = dbContext;
6467
_userService = userService;
6568
_localizationService = localizationService;
6669
_coreService = coreService;
6770
_baseDbContext = baseDbContext;
71+
_pushNotificationService = pushNotificationService;
6872
}
6973

7074
/// <summary>
@@ -159,6 +163,47 @@ public async Task<OperationDataResult<AbsenceRequestModel>> CreateAsync(AbsenceR
159163
.FirstAsync(ar => ar.Id == absenceRequest.Id);
160164

161165
var resultModel = MapToModel(createdRequest);
166+
167+
// Fire-and-forget push to manager(s)
168+
_ = Task.Run(async () =>
169+
{
170+
try
171+
{
172+
// Find managers for this worker's tags
173+
var sdkCore = await _coreService.GetCore();
174+
var sdkDbContext = sdkCore.DbContextHelper.GetDbContext();
175+
var workerTagIds = await sdkDbContext.SiteTags
176+
.Where(x => x.Site.MicrotingUid == model.RequestedBySdkSitId
177+
&& x.WorkflowState != Microting.eForm.Infrastructure.Constants.Constants.WorkflowStates.Removed)
178+
.Select(x => (int)x.TagId!)
179+
.ToListAsync();
180+
181+
var managerSiteIds = await _dbContext.AssignedSiteManagingTags
182+
.Where(x => x.WorkflowState != Microting.eForm.Infrastructure.Constants.Constants.WorkflowStates.Removed
183+
&& workerTagIds.Contains(x.TagId))
184+
.Select(x => x.AssignedSite!.SiteId)
185+
.Distinct()
186+
.ToListAsync();
187+
188+
foreach (var managerSiteId in managerSiteIds)
189+
{
190+
await _pushNotificationService.SendToSiteAsync(
191+
managerSiteId,
192+
"New absence request",
193+
"A worker has requested time off",
194+
new Dictionary<string, string>
195+
{
196+
{ "type", "absence_created" },
197+
{ "absenceRequestId", createdRequest.Id.ToString() }
198+
});
199+
}
200+
}
201+
catch (Exception ex)
202+
{
203+
_logger.LogError(ex, "Error sending push notification for absence request creation");
204+
}
205+
});
206+
162207
return new OperationDataResult<AbsenceRequestModel>(true, resultModel);
163208
}
164209
catch (Exception ex)
@@ -207,6 +252,29 @@ public async Task<OperationResult> ApproveAsync(int absenceRequestId, AbsenceReq
207252
await ApplyAbsenceToPlanRegistration(request, day);
208253
}
209254

255+
// Fire-and-forget push to requester
256+
var requesterSdkSitId = request.RequestedBySdkSitId;
257+
_ = Task.Run(async () =>
258+
{
259+
try
260+
{
261+
await _pushNotificationService.SendToSiteAsync(
262+
requesterSdkSitId,
263+
"Absence request approved",
264+
"Your absence request has been approved",
265+
new Dictionary<string, string>
266+
{
267+
{ "type", "absence_decided" },
268+
{ "action", "approved" },
269+
{ "absenceRequestId", absenceRequestId.ToString() }
270+
});
271+
}
272+
catch (Exception ex)
273+
{
274+
_logger.LogError(ex, "Error sending push notification for absence approval");
275+
}
276+
});
277+
210278
return new OperationResult(true);
211279
}
212280
catch (Exception ex)
@@ -240,6 +308,29 @@ public async Task<OperationResult> RejectAsync(int absenceRequestId, AbsenceRequ
240308
request.UpdatedByUserId = _userService.UserId;
241309
await request.Update(_dbContext);
242310

311+
// Fire-and-forget push to requester
312+
var requesterSdkSitId = request.RequestedBySdkSitId;
313+
_ = Task.Run(async () =>
314+
{
315+
try
316+
{
317+
await _pushNotificationService.SendToSiteAsync(
318+
requesterSdkSitId,
319+
"Absence request rejected",
320+
"Your absence request has been rejected",
321+
new Dictionary<string, string>
322+
{
323+
{ "type", "absence_decided" },
324+
{ "action", "rejected" },
325+
{ "absenceRequestId", absenceRequestId.ToString() }
326+
});
327+
}
328+
catch (Exception ex)
329+
{
330+
_logger.LogError(ex, "Error sending push notification for absence rejection");
331+
}
332+
});
333+
243334
return new OperationResult(true);
244335
}
245336
catch (Exception ex)

0 commit comments

Comments
 (0)