Add push notification
This commit is contained in:
parent
a452f4af04
commit
bad25bf5b3
224
ManagerService/Controllers/NotificationController.cs
Normal file
224
ManagerService/Controllers/NotificationController.cs
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
31
ManagerService/DTOs/PushNotificationDTO.cs
Normal file
31
ManagerService/DTOs/PushNotificationDTO.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
{
|
||||
|
||||
59
ManagerService/Data/PushNotification.cs
Normal file
59
ManagerService/Data/PushNotification.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
1389
ManagerService/Migrations/20260316114312_AddPushNotifications.Designer.cs
generated
Normal file
1389
ManagerService/Migrations/20260316114312_AddPushNotifications.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
ManagerService/Services/NotificationService.cs
Normal file
67
ManagerService/Services/NotificationService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user