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>
3.1 KiB
status, date, applyTo, supersededBy
| status | date | applyTo | supersededBy | |||||
|---|---|---|---|---|---|---|---|---|
| Accepted | 2026-07-10 |
|
0007 — Draft-editing signals are targeted via a dedicated hub with per-draft groups
Context
Backend-authoritative draft editing (ADR 0006) needs to notify exactly the one
browser editing a given draft that its cached state changed, is about to expire, or
was closed. The existing NotificationHub (ADR 0002) deliberately broadcasts every
business toast to all logged-in sessions and explicitly deferred per-user/targeted
delivery as "a new decision". Draft coordination pings are high-frequency, per-editor,
and must not spray to every session.
Decision
Draft signals use a dedicated SignalR hub, DraftPreviewHub, mapped at
/draftpreview (separate from NotificationHub at /notifications). Targeting is by
SignalR group named after the draft's session token:
- The client calls the hub methods
JoinDraft(token)/LeaveDraft(token)to subscribe/unsubscribe its connection to a draft's group. The hub carries no commands — only group membership (edits are POSTs; see ADR 0006). - The server sends via
IDraftNotifier(DraftNotifier) toClients.Group(token):draftReady{token,version}(re-fetch),draftExpiring{token,secondsLeft}(idle warning),draftClosed{token,reason}(session evicted/discarded → close the editor). - Like
EventService, 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.
Business success/failure messages for draft operations (e.g. "Zwischenstand
gespeichert") continue to flow through IEventService/NotificationHub, not this
hub — the two channels stay separate.
Consequences
- The session token doubles as the group name; it is an opaque GUID and must not encode sensitive data. Any browser that knows a token can join its group, so tokens must be treated as capabilities and only handed to the authenticated editor that opened the draft.
- Adding a new draft signal means adding a method to
IDraftNotifier+DraftNotifierand a client handler in$fis.draft— not overloading the business notification path. - ADR 0002 is unchanged:
NotificationHubstays broadcast-only for toasts. This hub is the answer to its "if per-user targeting becomes necessary, that is a new decision". - Multi-instance scale-out needs a SignalR backplane for group delivery — same limitation as ADR 0006.
Alternatives considered
- Reuse
NotificationHubwith groups: rejected — it would entangle broadcast toasts with targeted, high-frequency editing pings and force ADR 0002's broadcast contract to change. A separate hub keeps the concerns and their decisions independent. - Per-user groups (by account id): rejected — a user may open two drafts/tabs; per-draft-token groups target the precise editor and naturally support that.