Files
Fuchs_Intranet/Fuchs/Docs/Decisions/0007-targeted-draft-signalr-groups.md
StefanandClaude Opus 4.8 af445c015e 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>
2026-07-10 13:29:35 +02:00

3.1 KiB

status, date, applyTo, supersededBy
status date applyTo supersededBy
Accepted 2026-07-10
Fuchs/Notifications/DraftPreviewHub.cs
Fuchs/Notifications/IDraftNotifier.cs
Fuchs/Notifications/DraftNotifier.cs
Fuchs/Program.cs
Fuchs/js/intranet/**

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) to Clients.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 + DraftNotifier and a client handler in $fis.draft — not overloading the business notification path.
  • ADR 0002 is unchanged: NotificationHub stays 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 NotificationHub with 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.