diff --git a/clients/dashboard/src/sse/sse-context.tsx b/clients/dashboard/src/sse/sse-context.tsx index 6cd2648c3b..0a1c669e5a 100644 --- a/clients/dashboard/src/sse/sse-context.tsx +++ b/clients/dashboard/src/sse/sse-context.tsx @@ -98,7 +98,17 @@ async function* parseSseStream( export function SseProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState("idle"); - const [events, setEvents] = useState([]); + const [events, setEvents] = useState(() => { + try { + const stored = sessionStorage.getItem("fsh-sse-events"); + if (stored) { + return JSON.parse(stored); + } + } catch { + // ignore parse errors + } + return []; + }); const [eventCount, setEventCount] = useState(0); // We keep the running connection in refs so re-renders don't restart it. @@ -115,7 +125,13 @@ export function SseProvider({ children }: { children: ReactNode }) { }; setEvents((prev) => { const next = [entry, ...prev]; - return next.length > MAX_EVENTS ? next.slice(0, MAX_EVENTS) : next; + const sliced = next.length > MAX_EVENTS ? next.slice(0, MAX_EVENTS) : next; + try { + sessionStorage.setItem("fsh-sse-events", JSON.stringify(sliced)); + } catch { + // storage quota exceeded or unavailable + } + return sliced; }); setEventCount((c) => c + 1); }, []); diff --git a/src/Modules/Catalog/Modules.Catalog/Domain/Events/ProductDeletedDomainEvent.cs b/src/Modules/Catalog/Modules.Catalog/Domain/Events/ProductDeletedDomainEvent.cs new file mode 100644 index 0000000000..774c7fef0c --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Domain/Events/ProductDeletedDomainEvent.cs @@ -0,0 +1,9 @@ +using FSH.Framework.Core.Domain; + +namespace FSH.Modules.Catalog.Domain.Events; + +public sealed record ProductDeletedDomainEvent( + Guid ProductId, + Guid Id, + DateTimeOffset OccurredOnUtc) + : DomainEvent(Id, OccurredOnUtc); diff --git a/src/Modules/Catalog/Modules.Catalog/Domain/Events/ProductUpdatedDomainEvent.cs b/src/Modules/Catalog/Modules.Catalog/Domain/Events/ProductUpdatedDomainEvent.cs new file mode 100644 index 0000000000..4440406f1e --- /dev/null +++ b/src/Modules/Catalog/Modules.Catalog/Domain/Events/ProductUpdatedDomainEvent.cs @@ -0,0 +1,10 @@ +using FSH.Framework.Core.Domain; + +namespace FSH.Modules.Catalog.Domain.Events; + +public sealed record ProductUpdatedDomainEvent( + Guid ProductId, + string Name, + Guid Id, + DateTimeOffset OccurredOnUtc) + : DomainEvent(Id, OccurredOnUtc); diff --git a/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs b/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs index cd1bbc696b..e389bf74b2 100644 --- a/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs +++ b/src/Modules/Catalog/Modules.Catalog/Domain/Product.cs @@ -114,6 +114,9 @@ public void Update( CategoryId = categoryId; IsActive = isActive; UpdatedAtUtc = DateTime.UtcNow; + + AddDomainEvent(DomainEvent.Create((id, ts) => + new ProductUpdatedDomainEvent(Id, Name, id, ts))); } public void ChangePrice(Money newPrice) @@ -153,6 +156,12 @@ public void AdjustStock(int delta) new ProductStockAdjustedDomainEvent(Id, oldStock, newStock, delta, id, ts))); } + public void QueueDeleteEvent() + { + AddDomainEvent(DomainEvent.Create((id, ts) => + new ProductDeletedDomainEvent(Id, id, ts))); + } + // ─── Image management ───────────────────────────────────────────────── /// diff --git a/src/Modules/Catalog/Modules.Catalog/Events/CatalogEventHandlers.cs b/src/Modules/Catalog/Modules.Catalog/Events/CatalogEventHandlers.cs index 0a771d3964..98c6031c6e 100644 --- a/src/Modules/Catalog/Modules.Catalog/Events/CatalogEventHandlers.cs +++ b/src/Modules/Catalog/Modules.Catalog/Events/CatalogEventHandlers.cs @@ -1,41 +1,115 @@ +using System.Text.Json; +using FSH.Framework.Core.Context; +using FSH.Framework.Web.Sse; using FSH.Modules.Catalog.Domain.Events; using Mediator; using Microsoft.Extensions.Logging; namespace FSH.Modules.Catalog.Events; -public sealed class CatalogEventHandlers(ILogger logger) : +public sealed class CatalogEventHandlers( + ILogger logger, + SseConnectionManager sse, + ICurrentUser currentUser) : INotificationHandler, + INotificationHandler, + INotificationHandler, INotificationHandler, INotificationHandler { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + public ValueTask Handle(ProductCreatedDomainEvent notification, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(notification); - if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation("Handling ProductCreatedDomainEvent for ProductId: {ProductId}", notification.ProductId); + + BroadcastEvent("ProductCreated", new { - logger.LogInformation("Handling ProductCreatedDomainEvent for ProductId: {ProductId}", notification.ProductId); - } + notification.ProductId, + notification.Sku, + notification.Name, + }); + + return default; + } + + public ValueTask Handle(ProductUpdatedDomainEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + logger.LogInformation("Handling ProductUpdatedDomainEvent for ProductId: {ProductId}", notification.ProductId); + + BroadcastEvent("ProductUpdated", new + { + notification.ProductId, + notification.Name, + }); + + return default; + } + + public ValueTask Handle(ProductDeletedDomainEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + logger.LogInformation("Handling ProductDeletedDomainEvent for ProductId: {ProductId}", notification.ProductId); + + BroadcastEvent("ProductDeleted", new + { + notification.ProductId, + }); + return default; } public ValueTask Handle(ProductPriceChangedDomainEvent notification, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(notification); - if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation("Handling ProductPriceChangedDomainEvent for ProductId: {ProductId}", notification.ProductId); + + BroadcastEvent("ProductPriceChanged", new { - logger.LogInformation("Handling ProductPriceChangedDomainEvent for ProductId: {ProductId}", notification.ProductId); - } + notification.ProductId, + notification.OldAmount, + notification.NewAmount, + notification.Currency, + }); + return default; } public ValueTask Handle(ProductStockAdjustedDomainEvent notification, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(notification); - if (logger.IsEnabled(LogLevel.Information)) + logger.LogInformation("Handling ProductStockAdjustedDomainEvent for ProductId: {ProductId}", notification.ProductId); + + BroadcastEvent("ProductStockAdjusted", new { - logger.LogInformation("Handling ProductStockAdjustedDomainEvent for ProductId: {ProductId}", notification.ProductId); - } + notification.ProductId, + notification.OldStock, + notification.NewStock, + notification.Delta, + }); + return default; } + + /// + /// Broadcasts an SSE event scoped to the current tenant. Falls back to a global broadcast + /// when the tenant context is unavailable (e.g. background jobs without an HTTP context). + /// + private void BroadcastEvent(string eventType, object payload) + { + string data = JsonSerializer.Serialize(payload, JsonOptions); + var sseEvent = new SseEvent(eventType, data); + + string? tenantId = currentUser.GetTenant(); + if (!string.IsNullOrEmpty(tenantId)) + { + sse.Broadcast(tenantId, sseEvent); + } + else + { + sse.BroadcastAll(sseEvent); + } + } } diff --git a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs index 01be205865..472f36f99f 100644 --- a/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs +++ b/src/Modules/Catalog/Modules.Catalog/Features/v1/Products/DeleteProduct/DeleteProductCommandHandler.cs @@ -21,6 +21,7 @@ public async ValueTask Handle(DeleteProductCommand command, CancellationTo .ConfigureAwait(false) ?? throw new NotFoundException($"Product {command.ProductId} not found."); + product.QueueDeleteEvent(); dbContext.Products.Remove(product); await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return Unit.Value;