Add push notification

This commit is contained in:
Thomas Fransolet 2026-03-17 09:10:56 +01:00
parent a452f4af04
commit bad25bf5b3
10 changed files with 1883 additions and 0 deletions

View File

@ -0,0 +1,224 @@
using Hangfire;
using Manager.Services;
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSwag.Annotations;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace ManagerService.Controllers
{
[Authorize(Policy = ManagerService.Service.Security.Policies.InstanceAdmin)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Notification", Description = "Push notification management")]
public class NotificationController : ControllerBase
{
private readonly ILogger<NotificationController> _logger;
private readonly MyInfoMateDbContext _db;
private readonly NotificationService _notificationService;
IHexIdGeneratorService idService = new HexIdGeneratorService();
public NotificationController(
ILogger<NotificationController> logger,
MyInfoMateDbContext db,
NotificationService notificationService)
{
_logger = logger;
_db = db;
_notificationService = notificationService;
}
/// <summary>
/// Get all notifications for the current instance
/// </summary>
[ProducesResponseType(typeof(System.Collections.Generic.List<PushNotificationDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
public async Task<ObjectResult> Get()
{
try
{
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
var notifications = await _db.PushNotifications
.Where(n => n.InstanceId == instanceId)
.OrderByDescending(n => n.DateCreation)
.ToListAsync();
return new OkObjectResult(notifications.Select(n => n.ToDTO()));
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Send a push notification immediately to all devices of the instance
/// </summary>
[ProducesResponseType(typeof(PushNotificationDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("send")]
public async Task<ObjectResult> Send([FromBody] SendNotificationDTO dto)
{
try
{
if (dto == null)
throw new ArgumentNullException("Notification param is null");
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
var instance = await _db.Instances.FirstOrDefaultAsync(i => i.Id == instanceId);
if (instance == null || !instance.IsPushNotification)
throw new InvalidOperationException("Push notifications are not enabled for this instance");
var notification = new PushNotification
{
Id = idService.GenerateHexId(),
InstanceId = instanceId,
Title = dto.title,
Body = dto.body,
Topic = $"instance_{instanceId}",
Status = PushNotificationStatus.Scheduled,
DateCreation = DateTime.UtcNow
};
_db.PushNotifications.Add(notification);
await _db.SaveChangesAsync();
var jobId = BackgroundJob.Enqueue<NotificationService>(s => s.SendToTopicAsync(notification.Id));
notification.HangfireJobId = jobId;
await _db.SaveChangesAsync();
return new OkObjectResult(notification.ToDTO());
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message);
}
catch (InvalidOperationException ex)
{
return new ConflictObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Schedule a push notification for a future date/time
/// </summary>
[ProducesResponseType(typeof(PushNotificationDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("schedule")]
public async Task<ObjectResult> Schedule([FromBody] ScheduleNotificationDTO dto)
{
try
{
if (dto == null)
throw new ArgumentNullException("Notification param is null");
if (dto.scheduledAt <= DateTime.UtcNow)
throw new ArgumentException("Scheduled date must be in the future");
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
var instance = await _db.Instances.FirstOrDefaultAsync(i => i.Id == instanceId);
if (instance == null || !instance.IsPushNotification)
throw new InvalidOperationException("Push notifications are not enabled for this instance");
var notification = new PushNotification
{
Id = idService.GenerateHexId(),
InstanceId = instanceId,
Title = dto.title,
Body = dto.body,
Topic = $"instance_{instanceId}",
Status = PushNotificationStatus.Scheduled,
ScheduledAt = dto.scheduledAt.ToUniversalTime(),
DateCreation = DateTime.UtcNow
};
_db.PushNotifications.Add(notification);
await _db.SaveChangesAsync();
var delay = dto.scheduledAt.ToUniversalTime() - DateTime.UtcNow;
var jobId = BackgroundJob.Schedule<NotificationService>(s => s.SendToTopicAsync(notification.Id), delay);
notification.HangfireJobId = jobId;
await _db.SaveChangesAsync();
return new OkObjectResult(notification.ToDTO());
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message);
}
catch (ArgumentException ex)
{
return new BadRequestObjectResult(ex.Message);
}
catch (InvalidOperationException ex)
{
return new ConflictObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Cancel a scheduled notification
/// </summary>
[ProducesResponseType(typeof(string), 202)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpDelete("{id}")]
public async Task<ObjectResult> Cancel(string id)
{
try
{
var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value;
var notification = await _db.PushNotifications
.FirstOrDefaultAsync(n => n.Id == id && n.InstanceId == instanceId);
if (notification == null)
throw new KeyNotFoundException("Notification not found");
if (notification.Status != PushNotificationStatus.Scheduled)
throw new InvalidOperationException("Only scheduled notifications can be cancelled");
if (notification.HangfireJobId != null)
BackgroundJob.Delete(notification.HangfireJobId);
_db.PushNotifications.Remove(notification);
await _db.SaveChangesAsync();
return new ObjectResult("Notification cancelled") { StatusCode = 202 };
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message);
}
catch (InvalidOperationException ex)
{
return new ConflictObjectResult(ex.Message);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
}
}

View File

@ -0,0 +1,31 @@
using ManagerService.Data;
using System;
namespace ManagerService.DTOs
{
public class PushNotificationDTO
{
public string? id { get; set; }
public string instanceId { get; set; }
public string title { get; set; }
public string body { get; set; }
public string topic { get; set; }
public PushNotificationStatus status { get; set; }
public DateTime? scheduledAt { get; set; }
public DateTime? sentAt { get; set; }
public DateTime dateCreation { get; set; }
}
public class SendNotificationDTO
{
public string title { get; set; }
public string body { get; set; }
}
public class ScheduleNotificationDTO
{
public string title { get; set; }
public string body { get; set; }
public DateTime scheduledAt { get; set; }
}
}

View File

@ -45,6 +45,9 @@ namespace ManagerService.Data
// API Keys
public DbSet<ApiKey> ApiKeys { get; set; }
// Push Notifications
public DbSet<PushNotification> PushNotifications { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@ -0,0 +1,59 @@
using ManagerService.DTOs;
using Microsoft.EntityFrameworkCore;
using System;
using System.ComponentModel.DataAnnotations;
namespace ManagerService.Data
{
[Index(nameof(InstanceId))]
public class PushNotification
{
[Key]
public string Id { get; set; }
[Required]
public string InstanceId { get; set; }
[Required]
public string Title { get; set; }
[Required]
public string Body { get; set; }
[Required]
public string Topic { get; set; }
public PushNotificationStatus Status { get; set; }
public DateTime? ScheduledAt { get; set; }
public DateTime? SentAt { get; set; }
public string? HangfireJobId { get; set; }
public DateTime DateCreation { get; set; } = DateTime.UtcNow;
public PushNotificationDTO ToDTO()
{
return new PushNotificationDTO
{
id = Id,
instanceId = InstanceId,
title = Title,
body = Body,
topic = Topic,
status = Status,
scheduledAt = ScheduledAt,
sentAt = SentAt,
dateCreation = DateCreation
};
}
}
public enum PushNotificationStatus
{
Scheduled,
Sent,
Failed
}
}

View File

@ -21,6 +21,9 @@
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.3" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="9.0.3" />
<PackageReference Include="NSwag.AspNetCore" Version="13.10.8" />
<PackageReference Include="FirebaseAdmin" Version="3.1.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.17" />
<PackageReference Include="Hangfire.PostgreSql" Version="1.20.10" />
<PackageReference Include="Scrypt.NET" Version="1.3.0" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" />

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,47 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddPushNotifications : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PushNotifications",
columns: table => new
{
Id = table.Column<string>(type: "text", nullable: false),
InstanceId = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
Body = table.Column<string>(type: "text", nullable: false),
Topic = table.Column<string>(type: "text", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
ScheduledAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
SentAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
HangfireJobId = table.Column<string>(type: "text", nullable: true),
DateCreation = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PushNotifications", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_PushNotifications_InstanceId",
table: "PushNotifications",
column: "InstanceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "PushNotifications");
}
}
}

View File

@ -332,6 +332,49 @@ namespace ManagerService.Migrations
b.ToTable("Instances");
});
modelBuilder.Entity("ManagerService.Data.PushNotification", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("DateCreation")
.HasColumnType("timestamp with time zone");
b.Property<string>("HangfireJobId")
.HasColumnType("text");
b.Property<string>("InstanceId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("ScheduledAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("SentAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Topic")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("InstanceId");
b.ToTable("PushNotifications");
});
modelBuilder.Entity("ManagerService.Data.Resource", b =>
{
b.Property<string>("Id")

View File

@ -0,0 +1,17 @@
using Hangfire.Dashboard;
namespace ManagerService.Security
{
public class HangfireDashboardAuthorizationFilter : IDashboardAuthorizationFilter
{
public bool Authorize(DashboardContext context)
{
var httpContext = context.GetHttpContext();
var user = httpContext.User;
return user.Identity?.IsAuthenticated == true &&
user.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission,
ManagerService.Service.Security.Permissions.SuperAdmin);
}
}
}

View File

@ -0,0 +1,67 @@
using FirebaseAdmin;
using FirebaseAdmin.Messaging;
using Google.Apis.Auth.OAuth2;
using Hangfire;
using Manager.Services;
using ManagerService.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace ManagerService.Services
{
public class NotificationService
{
private readonly ILogger<NotificationService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public NotificationService(ILogger<NotificationService> logger, IServiceScopeFactory scopeFactory)
{
_logger = logger;
_scopeFactory = scopeFactory;
}
public async Task SendToTopicAsync(string notificationId)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MyInfoMateDbContext>();
IHexIdGeneratorService idService = new HexIdGeneratorService();
var notification = await db.PushNotifications.FirstOrDefaultAsync(n => n.Id == notificationId);
if (notification == null)
{
_logger.LogWarning("Notification {Id} not found", notificationId);
return;
}
try
{
var message = new Message
{
Topic = notification.Topic,
Notification = new Notification
{
Title = notification.Title,
Body = notification.Body
}
};
await FirebaseMessaging.DefaultInstance.SendAsync(message);
notification.Status = PushNotificationStatus.Sent;
notification.SentAt = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send notification {Id}", notificationId);
notification.Status = PushNotificationStatus.Failed;
}
await db.SaveChangesAsync();
}
}
}