Initial commit
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Web.Hubs;
|
||||
|
||||
namespace PowderCoating.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates and delivers in-app notifications: persists a record to <c>InAppNotifications</c> and
|
||||
/// then pushes a real-time update to connected clients via SignalR. Persistence happens first so
|
||||
/// that the notification is available in the bell history even if the SignalR push fails (e.g.,
|
||||
/// the recipient has no active browser session). SignalR failures are caught and logged at Warning
|
||||
/// level so that a transient hub error never rolls back the database write.
|
||||
/// </summary>
|
||||
public class InAppNotificationService : IInAppNotificationService
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IHubContext<NotificationHub> _hub;
|
||||
private readonly ILogger<InAppNotificationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the service with direct DbContext access, the SignalR hub context, and a logger.
|
||||
/// DbContext is injected directly (rather than through IUnitOfWork) because notification
|
||||
/// creation must commit immediately and independently of any surrounding business transaction.
|
||||
/// </summary>
|
||||
public InAppNotificationService(
|
||||
ApplicationDbContext db,
|
||||
IHubContext<NotificationHub> hub,
|
||||
ILogger<InAppNotificationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_hub = hub;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists an in-app notification for a specific tenant company and broadcasts it in real time
|
||||
/// to all users in that company's SignalR group ("company-{companyId}"). Optional FK parameters
|
||||
/// (<paramref name="quoteId"/>, <paramref name="invoiceId"/>, <paramref name="customerId"/>)
|
||||
/// allow the bell history page to deep-link directly to the related record.
|
||||
/// </summary>
|
||||
public async Task CreateAsync(int companyId, string title, string message, string notificationType,
|
||||
string? link = null, int? quoteId = null, int? invoiceId = null, int? customerId = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var notification = new InAppNotification
|
||||
{
|
||||
CompanyId = companyId,
|
||||
Title = title,
|
||||
Message = message,
|
||||
NotificationType = notificationType,
|
||||
Link = link,
|
||||
QuoteId = quoteId,
|
||||
InvoiceId = invoiceId,
|
||||
CustomerId = customerId,
|
||||
IsRead = false,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_db.InAppNotifications.Add(notification);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Group($"company-{companyId}").SendAsync("NewInAppNotification", new
|
||||
{
|
||||
id = notification.Id,
|
||||
title = notification.Title,
|
||||
message = notification.Message,
|
||||
link = notification.Link,
|
||||
notificationType = notification.NotificationType,
|
||||
createdAt = now.ToString("o")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to push in-app notification SignalR for company {CompanyId}", companyId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Broadcasts a notification to every active tenant company — one <c>InAppNotification</c> row
|
||||
/// per company so it appears in the bell for all users of that company. Used for platform-wide
|
||||
/// announcements such as new release notes. Skips the demo company (ID 1) since that is the
|
||||
/// SuperAdmin home company, not a real tenant. SignalR pushes are attempted for each company
|
||||
/// group; individual push failures are logged at Warning but do not stop the remaining companies.
|
||||
/// </summary>
|
||||
public async Task CreateForAllCompaniesAsync(string title, string message, string notificationType, string? link = null)
|
||||
{
|
||||
var companyIds = await _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted && c.Id != 1)
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var notifications = companyIds.Select(cid => new InAppNotification
|
||||
{
|
||||
CompanyId = cid,
|
||||
Title = title,
|
||||
Message = message,
|
||||
NotificationType = notificationType,
|
||||
Link = link,
|
||||
IsRead = false,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
}).ToList();
|
||||
|
||||
_db.InAppNotifications.AddRange(notifications);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
foreach (var notification in notifications)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Group($"company-{notification.CompanyId}").SendAsync("NewInAppNotification", new
|
||||
{
|
||||
id = notification.Id,
|
||||
title = notification.Title,
|
||||
message = notification.Message,
|
||||
link = notification.Link,
|
||||
notificationType = notification.NotificationType,
|
||||
createdAt = now.ToString("o")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to push release note SignalR notification for company {CompanyId}", notification.CompanyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a platform-level notification and pushes it to all connected SuperAdmin users via
|
||||
/// the "superadmin" SignalR group. Uses <c>CompanyId = 0</c> as a platform-level sentinel value
|
||||
/// (no tenant owns CompanyId 0) so that SuperAdmin notifications are stored in the same table
|
||||
/// without a tenant FK and are excluded from per-company global query filters.
|
||||
/// </summary>
|
||||
public async Task CreateForSuperAdminsAsync(string title, string message, string notificationType, string? link = null)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
// CompanyId = 0 is the platform-level convention — no tenant owns it
|
||||
var notification = new InAppNotification
|
||||
{
|
||||
CompanyId = 0,
|
||||
Title = title,
|
||||
Message = message,
|
||||
NotificationType = notificationType,
|
||||
Link = link,
|
||||
IsRead = false,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_db.InAppNotifications.Add(notification);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Group("superadmin").SendAsync("NewInAppNotification", new
|
||||
{
|
||||
id = notification.Id,
|
||||
title = notification.Title,
|
||||
message = notification.Message,
|
||||
link = notification.Link,
|
||||
notificationType = notification.NotificationType,
|
||||
createdAt = now.ToString("o")
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to push SuperAdmin in-app notification via SignalR");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace PowderCoating.Web.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a single user's most recent activity, recorded on each HTTP request.
|
||||
/// Stored in the in-memory <see cref="OnlineUserTracker"/> dictionary; not persisted to the database.
|
||||
/// </summary>
|
||||
public class OnlineUserEntry
|
||||
{
|
||||
/// <summary>Gets or sets the ASP.NET Identity user ID, used as the dictionary key.</summary>
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the user's email address for display in the admin online-users panel.</summary>
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the user's display name shown in the online-users list.</summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the name of the company the user belongs to, or null for SuperAdmins with no company.</summary>
|
||||
public string? CompanyName { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the user's company ID, used to filter the online-users list by tenant.</summary>
|
||||
public int? CompanyId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the user's highest system role for display in the admin panel.</summary>
|
||||
public string? Role { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether this user is a SuperAdmin, which controls what they can see across tenants.</summary>
|
||||
public bool IsSuperAdmin { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the URL path the user was on at the time of their last tracked request.</summary>
|
||||
public string? CurrentPath { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the user's IP address at the time of their last tracked request.</summary>
|
||||
public string? IpAddress { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the UTC timestamp of the user's most recent tracked request.</summary>
|
||||
public DateTime LastSeen { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks which users have been active within a configurable time window.
|
||||
/// Registered as a singleton; implementations must be thread-safe.
|
||||
/// </summary>
|
||||
public interface IOnlineUserTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Records or updates the activity entry for the given user. Called once per HTTP request
|
||||
/// from an action filter or middleware.
|
||||
/// </summary>
|
||||
void Touch(OnlineUserEntry entry);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all users whose <c>LastSeen</c> timestamp falls within the past
|
||||
/// <paramref name="windowMinutes"/> minutes, ordered most-recently-seen first.
|
||||
/// Stale entries are pruned as a side effect.
|
||||
/// </summary>
|
||||
IReadOnlyList<OnlineUserEntry> GetActiveUsers(int windowMinutes = 15);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of users active within the past <paramref name="windowMinutes"/> minutes
|
||||
/// without pruning stale entries. Used for the dashboard "online now" badge where full entry
|
||||
/// details are not needed.
|
||||
/// </summary>
|
||||
int GetActiveCount(int windowMinutes = 15);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe in-memory implementation of <see cref="IOnlineUserTracker"/> backed by a
|
||||
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by user ID.
|
||||
/// One entry per user is maintained (the latest touch overwrites the previous) so the memory
|
||||
/// footprint is bounded by the number of distinct users, not the number of requests.
|
||||
/// Stale entries are pruned lazily during <see cref="GetActiveUsers"/> to avoid a separate
|
||||
/// background timer and the complexity of concurrent timer + dictionary interactions.
|
||||
/// </summary>
|
||||
public class OnlineUserTracker : IOnlineUserTracker
|
||||
{
|
||||
// userId → entry
|
||||
private readonly ConcurrentDictionary<string, OnlineUserEntry> _sessions = new();
|
||||
|
||||
/// <summary>
|
||||
/// Upserts the activity entry for a user, replacing any previous entry for the same user ID.
|
||||
/// Thread-safe because <see cref="ConcurrentDictionary{TKey,TValue}"/> indexer is atomic.
|
||||
/// </summary>
|
||||
public void Touch(OnlineUserEntry entry)
|
||||
{
|
||||
_sessions[entry.UserId] = entry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all users active within the past <paramref name="windowMinutes"/> minutes and prunes
|
||||
/// entries that have gone stale. Pruning is done eagerly here (rather than in a background timer)
|
||||
/// to keep the implementation simple and avoid memory leaks when users log out without triggering
|
||||
/// a disconnect event.
|
||||
/// </summary>
|
||||
public IReadOnlyList<OnlineUserEntry> GetActiveUsers(int windowMinutes = 15)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddMinutes(-windowMinutes);
|
||||
// Prune stale entries while collecting
|
||||
var staleKeys = _sessions.Where(kv => kv.Value.LastSeen < cutoff).Select(kv => kv.Key).ToList();
|
||||
foreach (var key in staleKeys)
|
||||
_sessions.TryRemove(key, out _);
|
||||
|
||||
return _sessions.Values
|
||||
.Where(e => e.LastSeen >= cutoff)
|
||||
.OrderByDescending(e => e.LastSeen)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a quick count of active users without modifying the dictionary.
|
||||
/// Does not prune stale entries, so may include a small number of recently-expired sessions;
|
||||
/// this is acceptable for the dashboard counter where a ±1 approximation is fine.
|
||||
/// </summary>
|
||||
public int GetActiveCount(int windowMinutes = 15)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddMinutes(-windowMinutes);
|
||||
return _sessions.Values.Count(e => e.LastSeen >= cutoff);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user