Skip to content

Commit f31062a

Browse files
authored
feat: Comprehensive notification system with S3 integration
2 parents e4bcfc3 + 856f219 commit f31062a

27 files changed

+3377
-105
lines changed
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
using Microsoft.AspNetCore.Authorization;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.EntityFrameworkCore;
4+
using ThingConnect.Pulse.Server.Data;
5+
using ThingConnect.Pulse.Server.Models;
6+
using ThingConnect.Pulse.Server.Services;
7+
8+
namespace ThingConnect.Pulse.Server.Controllers;
9+
10+
[ApiController]
11+
[Route("api/[controller]")]
12+
[Authorize]
13+
public sealed class NotificationController : ControllerBase
14+
{
15+
private readonly PulseDbContext _context;
16+
private readonly ILogger<NotificationController> _logger;
17+
private readonly INotificationService _notificationService;
18+
19+
public NotificationController(PulseDbContext context, ILogger<NotificationController> logger, INotificationService notificationService)
20+
{
21+
_context = context;
22+
_logger = logger;
23+
_notificationService = notificationService;
24+
}
25+
26+
/// <summary>
27+
/// Get active notifications for the current user.
28+
/// </summary>
29+
/// <param name="includeRead">Whether to include already read notifications.</param>
30+
/// <returns>List of active notifications.</returns>
31+
[HttpGet]
32+
public async Task<ActionResult<IEnumerable<NotificationDto>>> GetNotificationsAsync(
33+
[FromQuery] bool includeRead = false)
34+
{
35+
try
36+
{
37+
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
38+
39+
var query = _context.Notifications
40+
.Where(n => n.ValidFromTs <= now && n.ValidUntilTs >= now);
41+
42+
if (!includeRead)
43+
{
44+
query = query.Where(n => !n.IsRead);
45+
}
46+
47+
var notifications = await query
48+
.OrderByDescending(n => n.Priority)
49+
.ThenByDescending(n => n.CreatedTs)
50+
.Select(n => new NotificationDto(
51+
n.Id,
52+
n.Type.ToString(),
53+
n.Priority.ToString(),
54+
n.Title,
55+
n.Message,
56+
n.ActionUrl,
57+
n.ActionText,
58+
DateTimeOffset.FromUnixTimeSeconds(n.ValidFromTs).DateTime,
59+
DateTimeOffset.FromUnixTimeSeconds(n.ValidUntilTs).DateTime,
60+
n.IsRead,
61+
n.IsShown,
62+
DateTimeOffset.FromUnixTimeSeconds(n.CreatedTs).DateTime
63+
))
64+
.ToListAsync();
65+
66+
_logger.LogDebug("Retrieved {Count} notifications (includeRead: {IncludeRead})",
67+
notifications.Count, includeRead);
68+
69+
return Ok(notifications);
70+
}
71+
catch (Exception ex)
72+
{
73+
_logger.LogError(ex, "Error retrieving notifications");
74+
return StatusCode(500, new { message = "Internal server error while retrieving notifications" });
75+
}
76+
}
77+
78+
/// <summary>
79+
/// Mark a notification as read.
80+
/// </summary>
81+
/// <param name="request">The notification ID to mark as read.</param>
82+
/// <returns>Success response.</returns>
83+
[HttpPost("mark-read")]
84+
public async Task<ActionResult> MarkNotificationReadAsync([FromBody] MarkNotificationReadRequest request)
85+
{
86+
try
87+
{
88+
var notification = await _context.Notifications
89+
.FirstOrDefaultAsync(n => n.Id == request.NotificationId);
90+
91+
if (notification == null)
92+
{
93+
return NotFound(new { message = "Notification not found" });
94+
}
95+
96+
if (!notification.IsRead)
97+
{
98+
notification.IsRead = true;
99+
notification.ReadTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
100+
101+
await _context.SaveChangesAsync();
102+
103+
_logger.LogDebug("Marked notification as read: {NotificationId}", request.NotificationId);
104+
}
105+
106+
return Ok(new { message = "Notification marked as read" });
107+
}
108+
catch (Exception ex)
109+
{
110+
_logger.LogError(ex, "Error marking notification as read: {NotificationId}", request.NotificationId);
111+
return StatusCode(500, new { message = "Internal server error while marking notification as read" });
112+
}
113+
}
114+
115+
/// <summary>
116+
/// Mark a notification as shown (for tracking purposes).
117+
/// </summary>
118+
/// <param name="notificationId">The notification ID to mark as shown.</param>
119+
/// <returns>Success response.</returns>
120+
[HttpPost("{notificationId}/mark-shown")]
121+
public async Task<ActionResult> MarkNotificationShownAsync(string notificationId)
122+
{
123+
try
124+
{
125+
var notification = await _context.Notifications
126+
.FirstOrDefaultAsync(n => n.Id == notificationId);
127+
128+
if (notification == null)
129+
{
130+
return NotFound(new { message = "Notification not found" });
131+
}
132+
133+
if (!notification.IsShown)
134+
{
135+
notification.IsShown = true;
136+
notification.ShownTs = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
137+
138+
await _context.SaveChangesAsync();
139+
140+
_logger.LogDebug("Marked notification as shown: {NotificationId}", notificationId);
141+
}
142+
143+
return Ok(new { message = "Notification marked as shown" });
144+
}
145+
catch (Exception ex)
146+
{
147+
_logger.LogError(ex, "Error marking notification as shown: {NotificationId}", notificationId);
148+
return StatusCode(500, new { message = "Internal server error while marking notification as shown" });
149+
}
150+
}
151+
152+
/// <summary>
153+
/// Get notification statistics and settings.
154+
/// </summary>
155+
/// <returns>Notification statistics.</returns>
156+
[HttpGet("stats")]
157+
public async Task<ActionResult> GetNotificationStatsAsync()
158+
{
159+
try
160+
{
161+
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
162+
163+
var activeCount = await _context.Notifications
164+
.CountAsync(n => n.ValidFromTs <= now && n.ValidUntilTs >= now);
165+
166+
var unreadCount = await _context.Notifications
167+
.CountAsync(n => n.ValidFromTs <= now && n.ValidUntilTs >= now && !n.IsRead);
168+
169+
var lastFetch = await _context.NotificationFetches
170+
.OrderByDescending(f => f.FetchTs)
171+
.FirstOrDefaultAsync();
172+
173+
var stats = new
174+
{
175+
ActiveNotifications = activeCount,
176+
UnreadNotifications = unreadCount,
177+
LastFetch = lastFetch != null
178+
? DateTimeOffset.FromUnixTimeSeconds(lastFetch.FetchTs).DateTime
179+
: (DateTime?)null,
180+
LastFetchSuccess = lastFetch?.Success ?? false,
181+
LastFetchError = lastFetch?.Error
182+
};
183+
184+
return Ok(stats);
185+
}
186+
catch (Exception ex)
187+
{
188+
_logger.LogError(ex, "Error retrieving notification stats");
189+
return StatusCode(500, new { message = "Internal server error while retrieving notification stats" });
190+
}
191+
}
192+
193+
/// <summary>
194+
/// Force refresh notifications from remote server (admin only).
195+
/// </summary>
196+
/// <returns>Success response.</returns>
197+
[HttpPost("refresh")]
198+
[Authorize(Policy = "AdminOnly")]
199+
public async Task<ActionResult> ForceRefreshNotificationsAsync()
200+
{
201+
try
202+
{
203+
_logger.LogInformation("Manual notification refresh requested by admin");
204+
205+
// Trigger the background service to fetch immediately
206+
await _notificationService.TriggerManualFetchAsync();
207+
208+
return Ok(new { message = "Notification refresh completed successfully" });
209+
}
210+
catch (Exception ex)
211+
{
212+
_logger.LogError(ex, "Error triggering notification refresh");
213+
return StatusCode(500, new { message = "Internal server error while refreshing notifications" });
214+
}
215+
}
216+
}

ThingConnect.Pulse.Server/Data/Entities.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,43 @@ public sealed class MonitoringSession
128128
public string? ShutdownReason { get; set; }
129129
public string? Version { get; set; }
130130
}
131+
132+
public enum NotificationType { info, warning, release, maintenance }
133+
public enum NotificationPriority { low, medium, high, critical }
134+
135+
/// <summary>
136+
/// Represents a notification fetched from the remote server for display to users.
137+
/// </summary>
138+
public sealed class Notification
139+
{
140+
public string Id { get; set; } = default!;
141+
public NotificationType Type { get; set; }
142+
public NotificationPriority Priority { get; set; }
143+
public string Title { get; set; } = default!;
144+
public string Message { get; set; } = default!;
145+
public string? ActionUrl { get; set; }
146+
public string? ActionText { get; set; }
147+
public long ValidFromTs { get; set; }
148+
public long ValidUntilTs { get; set; }
149+
public string? TargetVersions { get; set; }
150+
public bool ShowOnce { get; set; }
151+
public bool IsRead { get; set; }
152+
public bool IsShown { get; set; }
153+
public long CreatedTs { get; set; }
154+
public long? ReadTs { get; set; }
155+
public long? ShownTs { get; set; }
156+
}
157+
158+
/// <summary>
159+
/// Tracks the last successful notification fetch from the remote server.
160+
/// </summary>
161+
public sealed class NotificationFetch
162+
{
163+
public long Id { get; set; }
164+
public long FetchTs { get; set; }
165+
public string RemoteVersion { get; set; } = default!;
166+
public string RemoteLastUpdated { get; set; } = default!;
167+
public int NotificationCount { get; set; }
168+
public bool Success { get; set; }
169+
public string? Error { get; set; }
170+
}

ThingConnect.Pulse.Server/Data/PulseDbContext.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public sealed class PulseDbContext : IdentityDbContext<ApplicationUser>
1515
public DbSet<Setting> Settings => Set<Setting>();
1616
public DbSet<ConfigVersion> ConfigVersions => Set<ConfigVersion>();
1717
public DbSet<MonitoringSession> MonitoringSessions => Set<MonitoringSession>();
18+
public DbSet<Notification> Notifications => Set<Notification>();
19+
public DbSet<NotificationFetch> NotificationFetches => Set<NotificationFetch>();
1820

1921
public PulseDbContext(DbContextOptions<PulseDbContext> options)
2022
: base(options) { }
@@ -114,5 +116,34 @@ protected override void OnModelCreating(ModelBuilder b)
114116
e.HasIndex(x => x.StartedTs);
115117
e.HasIndex(x => x.EndedTs);
116118
});
119+
120+
b.Entity<Notification>(e =>
121+
{
122+
e.ToTable("notification");
123+
e.HasKey(x => x.Id);
124+
e.Property(x => x.Id).HasMaxLength(64);
125+
e.Property(x => x.Type).HasConversion<string>().IsRequired();
126+
e.Property(x => x.Priority).HasConversion<string>().IsRequired();
127+
e.Property(x => x.Title).IsRequired().HasMaxLength(200);
128+
e.Property(x => x.Message).IsRequired().HasMaxLength(1000);
129+
e.Property(x => x.ActionUrl).HasMaxLength(512);
130+
e.Property(x => x.ActionText).HasMaxLength(100);
131+
e.Property(x => x.TargetVersions).HasMaxLength(200);
132+
e.HasIndex(x => x.ValidFromTs);
133+
e.HasIndex(x => x.ValidUntilTs);
134+
e.HasIndex(x => new { x.IsRead, x.ValidFromTs });
135+
e.HasIndex(x => new { x.Priority, x.ValidFromTs });
136+
});
137+
138+
b.Entity<NotificationFetch>(e =>
139+
{
140+
e.ToTable("notification_fetch");
141+
e.HasKey(x => x.Id);
142+
e.Property(x => x.RemoteVersion).IsRequired().HasMaxLength(50);
143+
e.Property(x => x.RemoteLastUpdated).IsRequired().HasMaxLength(50);
144+
e.Property(x => x.Error).HasMaxLength(500);
145+
e.HasIndex(x => x.FetchTs);
146+
e.HasIndex(x => x.Success);
147+
});
117148
}
118149
}

0 commit comments

Comments
 (0)