Add backend-authoritative invoice draft editing (ADR 0006/0007)
Move the invoice draft editor to a backend single source of truth: an in-memory InvoiceDraftSession (per-token, cached) holds the editable payload, server-computed sums/VAT and validation, plus an automatic change history. The browser posts single edits; the server recomputes and signals the editing session over a dedicated SignalR hub (DraftPreviewHub) to re-fetch. This reverses the previously-documented stateless editor (EVAL_live_invoice_editing, INVOICE_LIFECYCLE §10), by explicit product decision — captured in ADR 0006 and 0007 plus the live-draft-editing concept doc. Backend (this milestone): - InvoiceDraftSession + ChangeHistoryEntry data holders - InvoiceDraftCalculator: pure port of quantChange/invSumUpdate (§13b, VAT-by-rate) and consistency checks — fully unit-tested - IInvoiceDraftCache/InvoiceDraftCache: in-memory store with idle sliding TTL - IInvoiceDraftService/InvoiceDraftEditService: open (payload or DB reload), patch, build state, flush via existing RegisterInvoiceAsync (no new persistence), preview from cache, discard (DB reload), history - InvoiceDraftExpiryService: pre-expiry warning + eviction-with-reason - DraftPreviewHub + IDraftNotifier/DraftNotifier: targeted draftReady/draftExpiring/ draftClosed signals per draft token - inv/dopen|dstate|dpatch|dpreview|dsave|dhistory|ddiscard|dclose endpoints; save reports success/failure via the existing EventService - DI + hub mapping in Program.cs Frontend (additive foundation): $fis.draft SignalR client for /draftpreview. The editor DOM inversion (routing deltas, rendering from server state) is the next, separately-verified step; existing endpoints are unaffected. Tests: 30 new (calculator, cache, expiry, patch/history, flush); 306 total passing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
e53d8962ad
commit
af445c015e
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IDraftNotifier"/> over the <see cref="DraftPreviewHub"/>. Sends to the
|
||||
/// SignalR group named after the draft token so only the editing browser is notified.
|
||||
/// Like <see cref="EventService.PublishAsync"/>, delivery failures are logged and
|
||||
/// swallowed — a missed coordination ping must never fail the underlying operation
|
||||
/// (the client also re-syncs on reconnect and on its next POST).
|
||||
/// </summary>
|
||||
public sealed class DraftNotifier : IDraftNotifier
|
||||
{
|
||||
private readonly IHubContext<DraftPreviewHub> _hub;
|
||||
private readonly ILogger<DraftNotifier> _logger;
|
||||
|
||||
public DraftNotifier(IHubContext<DraftPreviewHub> hub, ILogger<DraftNotifier> logger)
|
||||
{
|
||||
_hub = hub;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(token, "draftReady", new { token, version }, cancellationToken);
|
||||
|
||||
public Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(token, "draftExpiring", new { token, secondsLeft }, cancellationToken);
|
||||
|
||||
public Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default) =>
|
||||
SendAsync(token, "draftClosed", new { token, reason }, cancellationToken);
|
||||
|
||||
private async Task SendAsync(string token, string method, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(token)) return;
|
||||
try
|
||||
{
|
||||
await _hub.Clients.Group(token).SendAsync(method, payload, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Draft signal {Method} failed for token {Token}", method, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR hub for live invoice/reminder draft editing (see ADR 0006 / 0007).
|
||||
///
|
||||
/// Deliberately separate from <see cref="NotificationHub"/>: that hub broadcasts
|
||||
/// business toasts to <b>all</b> logged-in sessions (ADR 0002), whereas draft
|
||||
/// signals must be <b>targeted</b> at the one browser editing a given draft.
|
||||
/// Targeting is done with a SignalR group named after the draft's session token —
|
||||
/// each editor calls <see cref="JoinDraft"/> after opening a draft.
|
||||
///
|
||||
/// The hub carries no commands: edits, saves and discards travel as ordinary POSTs
|
||||
/// (see ADR 0006). The hub only manages group membership and delivers the server's
|
||||
/// <c>draftReady</c> / <c>draftExpiring</c> / <c>draftClosed</c> signals.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public sealed class DraftPreviewHub : Hub
|
||||
{
|
||||
/// <summary>Subscribes this connection to a draft's signal group.</summary>
|
||||
public Task JoinDraft(string token) =>
|
||||
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||
: Groups.AddToGroupAsync(Context.ConnectionId, token);
|
||||
|
||||
/// <summary>Unsubscribes this connection from a draft's signal group.</summary>
|
||||
public Task LeaveDraft(string token) =>
|
||||
string.IsNullOrEmpty(token) ? Task.CompletedTask
|
||||
: Groups.RemoveFromGroupAsync(Context.ConnectionId, token);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
/// <summary>
|
||||
/// Sends <b>system-internal</b> draft-editing signals to the one browser editing a
|
||||
/// given draft, over the <see cref="DraftPreviewHub"/> group keyed by session token
|
||||
/// (see ADR 0006 / 0007). These are coordination pings, not business notifications:
|
||||
/// user-facing success/failure messages (e.g. "Zwischenstand gespeichert") still go
|
||||
/// through <see cref="IEventService"/> / <see cref="NotificationHub"/>.
|
||||
/// </summary>
|
||||
public interface IDraftNotifier
|
||||
{
|
||||
/// <summary>The cached draft reached a new <paramref name="version"/> — the client should re-fetch its state.</summary>
|
||||
Task SignalDraftReadyAsync(string token, int version, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The draft is about to expire in <paramref name="secondsLeft"/>s unless saved — warn the user.</summary>
|
||||
Task SignalExpiringAsync(string token, int secondsLeft, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>The draft session was removed (evicted/expired/discarded) — the client must close the editor and show why.</summary>
|
||||
Task SignalClosedAsync(string token, string reason, CancellationToken cancellationToken = default);
|
||||
}
|
||||
Reference in New Issue
Block a user