Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -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&nbsp;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