Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions clients/dashboard/src/sse/sse-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,17 @@ async function* parseSseStream(

export function SseProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<SseStatus>("idle");
const [events, setEvents] = useState<SseEvent[]>([]);
const [events, setEvents] = useState<SseEvent[]>(() => {
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.
Expand All @@ -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);
}, []);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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);
9 changes: 9 additions & 0 deletions src/Modules/Catalog/Modules.Catalog/Domain/Product.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────

/// <summary>
Expand Down
94 changes: 84 additions & 10 deletions src/Modules/Catalog/Modules.Catalog/Events/CatalogEventHandlers.cs
Original file line number Diff line number Diff line change
@@ -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<CatalogEventHandlers> logger) :
public sealed class CatalogEventHandlers(
ILogger<CatalogEventHandlers> logger,
SseConnectionManager sse,
ICurrentUser currentUser) :
INotificationHandler<ProductCreatedDomainEvent>,
INotificationHandler<ProductUpdatedDomainEvent>,
INotificationHandler<ProductDeletedDomainEvent>,
INotificationHandler<ProductPriceChangedDomainEvent>,
INotificationHandler<ProductStockAdjustedDomainEvent>
{
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;
}

/// <summary>
/// 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).
/// </summary>
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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public async ValueTask<Unit> 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;
Expand Down