Changed Reminder Systematics analogue to invoices

This commit is contained in:
Stefan
2026-07-10 22:50:26 +02:00
parent 83d1c28b29
commit 5c0fdc6c1d
24 changed files with 1810 additions and 145 deletions
+27
View File
@@ -0,0 +1,27 @@
using Fuchs.intranet;
namespace Fuchs.Services;
/// <summary>
/// In-memory store of live reminder draft editing sessions (see ADR 0006, mirroring
/// <see cref="IInvoiceDraftCache"/>). Singleton, single-instance only — scale-out would
/// need a distributed cache / sticky sessions (documented limitation). Keyed by the
/// session token.
/// </summary>
public interface IReminderDraftCache
{
/// <summary>Stores (or replaces) a session under its token.</summary>
void Set(ReminderDraftSession session);
/// <summary>Returns the session for the token, or null if absent/evicted. Touches <c>LastAccessUtc</c> on hit.</summary>
ReminderDraftSession? Get(string token);
/// <summary>Removes the session (explicit close/discard/finalise). Returns the removed session, if any.</summary>
ReminderDraftSession? Remove(string token);
/// <summary>Snapshot of all live sessions — used by the expiry monitor. Does not touch access time.</summary>
IReadOnlyList<ReminderDraftSession> Snapshot();
/// <summary>The configured idle time-to-live before a session is eligible for eviction.</summary>
TimeSpan IdleTtl { get; }
}
+73
View File
@@ -0,0 +1,73 @@
using Fuchs.intranet;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
namespace Fuchs.Services;
/// <summary>
/// Orchestrates a live, backend-authoritative reminder draft editing session (ADR 0006,
/// mirroring <see cref="IInvoiceDraftService"/>). Owns the lifecycle around a
/// <see cref="ReminderDraftSession"/>: open (seed the cache), apply single edits, build the
/// view state, render a PDF preview from the cache, flush to the DB ("Zwischenspeichern")
/// and expose the change history. The open amount is aggregated by
/// <see cref="ReminderDraftCalculator"/> — the browser never sums.
///
/// Reload/discard is handled by the client (re-fetch the DB draft / prep data via the
/// existing <c>rem/get</c> path and re-seed), so there is no server-side DB reshaping here.
/// </summary>
public interface IReminderDraftService
{
/// <summary>
/// Seeds a new cache session from the editor's assembled payload (<c>new</c> / <c>rem</c>
/// blocks). Computes the open amount + validation and returns the session (with its fresh
/// token/version). A <c>remid</c> in the payload marks it as an update of an existing DB draft.
/// </summary>
ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId);
/// <summary>Returns the cached session for the token (touching its TTL), or null if absent/expired.</summary>
ReminderDraftSession? Get(string token);
/// <summary>
/// Applies one editor change to the cached session: mutates the payload, re-aggregates the
/// open amount, re-validates, appends a history entry and bumps the version. Returns the
/// mutated session, or null if the token is unknown.
/// </summary>
ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta);
/// <summary>Builds the JSON view-state DTO the frontend renders (payload + server sums + validation + version).</summary>
object BuildState(ReminderDraftSession session);
/// <summary>The draft's change history for the "Änderungshistorie" dialog (empty if the token is unknown).</summary>
IReadOnlyList<ChangeHistoryEntry> GetHistory(string token);
/// <summary>
/// Persists the cached session to the DB via the existing reminder registration path
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success.
/// Returns the registered reminder data (for the success event), or null if the token is unknown.
/// </summary>
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
bool Close(string token);
}
/// <summary>
/// A single editor change posted to <c>rem/dpatch</c>. <see cref="Target"/> names the
/// field/operation (e.g. "email", "subject", "amount"); <see cref="Ref"/> is reserved for
/// future per-item edits; <see cref="Value"/> is the new value (a scalar for fields, or a
/// small object for <c>contact</c>).
/// </summary>
public sealed class ReminderDraftDelta
{
public string Target { get; set; } = "";
public string Ref { get; set; } = "";
public JToken? Value { get; set; }
/// <summary>The new value as a string (empty when null), for history and simple field assignments.</summary>
public string ValueString =>
Value == null || Value.Type == JTokenType.Null ? "" : Value.Type == JTokenType.String ? Value.Value<string>() ?? "" : Value.ToString();
}
+61
View File
@@ -0,0 +1,61 @@
using System.Collections.Concurrent;
using Fuchs.intranet;
using Microsoft.Extensions.Configuration;
namespace Fuchs.Services;
/// <summary>
/// Single-instance, in-memory implementation of <see cref="IReminderDraftCache"/> backed
/// by a <see cref="ConcurrentDictionary{TKey,TValue}"/> keyed by session token — the
/// reminder mirror of <see cref="InvoiceDraftCache"/>. A plain dictionary (rather than
/// <c>IMemoryCache</c>) is used on purpose: the <see cref="ReminderDraftExpiryService"/>
/// needs to enumerate sessions and warn the user <b>before</b> eviction, which opaque
/// cache-entry expiry does not allow.
///
/// Idle TTL and the pre-expiry warning lead time are shared with invoices under
/// <c>Fuchs:DraftEditing</c> (<c>IdleMinutes</c> / <c>ExpiryWarnMinutes</c>).
/// </summary>
public sealed class ReminderDraftCache : IReminderDraftCache
{
private readonly ConcurrentDictionary<string, ReminderDraftSession> _sessions = new(StringComparer.Ordinal);
public TimeSpan IdleTtl { get; }
/// <summary>How long before the idle TTL a warning is emitted to the user.</summary>
public TimeSpan ExpiryWarnLead { get; }
public ReminderDraftCache(IConfiguration configuration)
{
int idleMinutes = configuration.GetValue("Fuchs:DraftEditing:IdleMinutes", 30);
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
IdleTtl = TimeSpan.FromMinutes(Math.Max(1, idleMinutes));
ExpiryWarnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, idleMinutes - 1)));
}
public void Set(ReminderDraftSession session)
{
if (string.IsNullOrEmpty(session.Token)) throw new ArgumentException("Session has no token.", nameof(session));
session.Touch();
_sessions[session.Token] = session;
}
public ReminderDraftSession? Get(string token)
{
if (string.IsNullOrEmpty(token)) return null;
if (_sessions.TryGetValue(token, out var s))
{
s.Touch();
// A touch resets the idle window, so a fresh warning is due next time it lapses.
s.ExpiryWarningSent = false;
return s;
}
return null;
}
public ReminderDraftSession? Remove(string token)
{
if (string.IsNullOrEmpty(token)) return null;
return _sessions.TryRemove(token, out var s) ? s : null;
}
public IReadOnlyList<ReminderDraftSession> Snapshot() => _sessions.Values.ToList();
}
+275
View File
@@ -0,0 +1,275 @@
using System.Globalization;
using Fuchs.intranet;
using Microsoft.Extensions.Logging;
using MigraDoc.DocumentObjectModel;
using Newtonsoft.Json.Linq;
using OCORE.security;
using static OCORE.commons;
using static OCORE.OCORE_dictionaries;
namespace Fuchs.Services;
/// <summary>
/// Backend-authoritative reminder draft editing (ADR 0006) — the reminder mirror of
/// <see cref="InvoiceDraftEditService"/>. Holds the truth in a
/// <see cref="ReminderDraftSession"/> (via <see cref="IReminderDraftCache"/>), applies
/// single edits, aggregates the open amount with <see cref="ReminderDraftCalculator"/>,
/// renders previews and flushes to the DB by reusing the existing
/// <see cref="IReminderService"/> registration path — no new persistence. The session
/// stores the editor's own block shape (<c>new</c>/<c>rem</c>), which the PDF/persistence
/// already consume, so nothing is re-shaped server-side.
/// </summary>
public sealed class ReminderDraftEditService : IReminderDraftService
{
private readonly IReminderDraftCache _cache;
private readonly IReminderService _reminders;
private readonly ILogger<ReminderDraftEditService> _logger;
public ReminderDraftEditService(IReminderDraftCache cache, IReminderService reminders,
ILogger<ReminderDraftEditService> logger)
{
_cache = cache;
_reminders = reminders;
_logger = logger;
}
// ── Open ─────────────────────────────────────────────────────────────────
public ReminderDraftSession OpenFromPayload(JObject payload, string userAccountId)
{
var session = new ReminderDraftSession
{
Token = NewToken(),
UserAccountId = userAccountId,
RemId = payload["remid"]?.Value<string>() ?? payload["id"]?.Value<string>() ?? ""
};
session.New = payload["new"] as JObject ?? new JObject();
session.Rem = payload["rem"] as JObject ?? new JObject();
Refresh(session);
_cache.Set(session);
_logger.LogInformation("Reminder draft session {Token} opened from payload (remId={RemId}, user={User})",
session.Token, session.RemId, userAccountId);
return session;
}
public ReminderDraftSession? Get(string token) => _cache.Get(token);
// ── Patch ──────────────────────────────────────────────────────────────────
public ReminderDraftSession? ApplyPatch(string token, ReminderDraftDelta delta)
{
var session = _cache.Get(token);
if (session == null) return null;
string oldValue = "", newValue = "";
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
if (!mutated)
{
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
return session;
}
Refresh(session);
session.Version++;
session.History.Add(new ChangeHistoryEntry
{
UserAccountId = session.UserAccountId,
Target = delta.Target,
Ref = delta.Ref,
OldValue = oldValue,
NewValue = newValue,
Version = session.Version
});
_cache.Set(session);
return session;
}
/// <summary>
/// Applies one delta to the payload; returns whether anything changed and captures the prior
/// and new value for the change history. Scalar text fields are sanitised from the editor's
/// HTML (TinyMCE wraps inline edits in <c>&lt;p&gt;…&lt;/p&gt;</c>) to plain text (via
/// <see cref="InvoiceDraftEditService.HtmlToPlain"/>) — the backend is the single source of
/// truth (ADR 0006), so no HTML ever reaches the DB or the PDF.
/// </summary>
private static bool ApplyDelta(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
switch (d.Target)
{
case "email": return SetNewText(s, "invoiceemail", d, ref oldValue, ref newValue);
case "address": return SetNewText(s, "invoiceaddress", d, ref oldValue, ref newValue);
case "subject": return SetNewText(s, "subject", d, ref oldValue, ref newValue);
case "text": return SetNewText(s, "text", d, ref oldValue, ref newValue);
case "amount": return SetNewNumber(s, "amount", d, ref oldValue, ref newValue);
case "amount_payed": return SetNewNumber(s, "amount_payed", d, ref oldValue, ref newValue);
case "contact": return SetContact(s, d, ref oldValue, ref newValue);
default: return false;
}
}
private static bool SetNewText(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.New[key]);
newValue = InvoiceDraftEditService.HtmlToPlain(d.ValueString);
s.New[key] = newValue;
return true;
}
/// <summary>Stores a numeric field, normalising German/invariant input to an invariant decimal string.</summary>
private static bool SetNewNumber(ReminderDraftSession s, string key, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
oldValue = Str(s.New[key]);
decimal parsed = ReminderDraftCalculator.Dec(d.Value ?? JValue.CreateString(InvoiceDraftEditService.HtmlToPlain(d.ValueString)));
newValue = parsed.ToString(CultureInfo.InvariantCulture);
s.New[key] = newValue;
return true;
}
private static bool SetContact(ReminderDraftSession s, ReminderDraftDelta d, ref string oldValue, ref string newValue)
{
JObject prev = TryParseObject(Str(s.New["CustomValues"]));
oldValue = ContactLabel(Str(prev["contactName"]), Str(prev["contactEmail"]));
JObject cvo = (JObject)prev.DeepClone();
if (d.Value is JObject vo)
{
cvo["contactName"] = vo["name"] ?? vo["contactName"] ?? "";
cvo["contactEmail"] = vo["email"] ?? vo["contactEmail"] ?? "";
}
s.New["CustomValues"] = cvo.ToString(Newtonsoft.Json.Formatting.None);
newValue = ContactLabel(Str(cvo["contactName"]), Str(cvo["contactEmail"]));
return true;
}
private static string ContactLabel(string name, string email) =>
string.IsNullOrEmpty(name) ? email : string.IsNullOrEmpty(email) ? name : $"{name} <{email}>";
// ── View state / history ────────────────────────────────────────────────
public object BuildState(ReminderDraftSession session)
{
session.Touch();
return new
{
token = session.Token,
version = session.Version,
remid = session.RemId,
isDraft = session.IsDraft,
@new = session.New,
rem = session.Rem,
sums = new
{
amount_total = session.Sums.AmountTotal,
amount_payed = session.Sums.AmountPayed,
amount_open = session.Sums.AmountOpen
},
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
historyCount = session.History.Count
};
}
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
// ── Flush / preview ────────────────────────────────────────────────────────
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildReminderData(session);
bool change = !string.IsNullOrEmpty(session.RemId);
var reg = await _reminders.RegisterReminderAsync(fds, change, session.RemId, userAccountId, dbSec);
if (!string.IsNullOrEmpty(reg.Id))
{
session.RemId = reg.Id;
_cache.Set(session);
_logger.LogInformation("Reminder draft {Token} flushed to DB reminder {RemId} (change={Change}, user={User})",
token, reg.Id, change, userAccountId);
}
return reg;
}
public Document? RenderPreview(string token)
{
var session = _cache.Get(token);
if (session == null) return null;
var fds = BuildReminderData(session);
fds.ReminderRegistration = SynthesizeRegistration(session);
fds.IsDraft = true;
return _reminders.GenerateReminderPdf(fds, draft: true);
}
public bool Close(string token) => _cache.Remove(token) != null;
// ── Internals ──────────────────────────────────────────────────────────────
private static void Refresh(ReminderDraftSession session)
{
ReminderDraftCalculator.RecomputeTotals(session);
ReminderDraftCalculator.Validate(session);
}
private static string NewToken() => Guid.NewGuid().ToString("N");
/// <summary>
/// Builds the <see cref="FdsReminderData"/> from the session — the server-side equivalent of
/// the editor's <c>remc</c> payload. The session already holds the editor's <c>new</c>/<c>rem</c>
/// shape that registration consumes, so the blocks pass through unchanged.
/// </summary>
private static FdsReminderData BuildReminderData(ReminderDraftSession session)
{
var jobj = new JObject
{
["new"] = session.New.DeepClone(),
["rem"] = session.Rem.DeepClone()
};
return new FdsReminderData(jobj);
}
/// <summary>
/// Synthesises the <c>ReminderRegistration</c> dictionary a draft PDF render needs, straight
/// from the cached session — so a preview requires no DB round-trip and no client upload.
/// Mirrors the columns <c>fds__getReminder</c>/<c>fds__createReminder</c> would return for a
/// draft, including the single-invoice <c>invoices</c> row the reminder table renders.
/// </summary>
private static GenericObjectDictionary SynthesizeRegistration(ReminderDraftSession session)
{
string invoiceId = Str(session.Rem["invoiceid"]).ne(Str(session.Rem["InvoiceId"]));
var invoices = new JArray
{
new JObject
{
["InvoiceDate"] = Str(session.Rem["invoicedate"]),
["DocumentName"] = "",
["InvoiceTitle"] = string.IsNullOrEmpty(invoiceId) ? "" : $"Rechnung {invoiceId}",
["InvoiceBalance"] = session.Sums.AmountTotal,
["amount_open"] = session.Sums.AmountOpen
}
};
var d = new Dictionary<string, object>
{
["Id"] = session.RemId,
["type"] = Str(session.Rem["type"]).ne("R"),
["subject"] = Str(session.New["subject"]),
["SendToAddress"] = Str(session.New["invoiceaddress"]),
["SendToEmail"] = Str(session.New["invoiceemail"]),
["InvoiceId"] = invoiceId,
["amount_open"] = session.Sums.AmountOpen,
["PaymentTerm"] = Str(session.Rem["paymentterm"]),
["invoices"] = invoices,
["CustomValues"] = Str(session.New["CustomValues"]),
["IsFinal"] = false,
["DateCreated"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)
};
return new GenericObjectDictionary(d);
}
// ── token helpers ─────────────────────────────────────────────────────────
private static string Str(JToken? t) =>
t == null || t.Type == JTokenType.Null ? "" : t.Type == JTokenType.String ? t.Value<string>() ?? "" : t.ToString();
private static JObject TryParseObject(string json)
{
if (!string.IsNullOrWhiteSpace(json) && json.TrimStart().StartsWith('{'))
{
try { return JObject.Parse(json); } catch { /* fall through */ }
}
return new JObject();
}
}
@@ -0,0 +1,69 @@
using Fuchs.Notifications;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Fuchs.Services;
/// <summary>
/// Background monitor for the reminder draft cache (ADR 0006) — the reminder mirror of
/// <see cref="InvoiceDraftExpiryService"/>. Because a draft's truth lives only in server
/// memory until the user saves, idle sessions must not vanish silently: this service warns
/// the editing browser <b>before</b> a session's idle TTL lapses ("bitte zwischenspeichern"),
/// and when the TTL is finally reached it evicts the session and tells the browser to close
/// the editor with a reason. All hints travel over the shared <see cref="DraftPreviewHub"/>
/// via <see cref="IDraftNotifier"/> (the token-keyed groups serve invoices and reminders alike).
/// </summary>
public sealed class ReminderDraftExpiryService : BackgroundService
{
private readonly IReminderDraftCache _cache;
private readonly IDraftNotifier _notifier;
private readonly ILogger<ReminderDraftExpiryService> _logger;
private readonly TimeSpan _warnLead;
private readonly TimeSpan _interval;
public ReminderDraftExpiryService(IReminderDraftCache cache, IDraftNotifier notifier,
IConfiguration configuration, ILogger<ReminderDraftExpiryService> logger)
{
_cache = cache;
_notifier = notifier;
_logger = logger;
int warnMinutes = configuration.GetValue("Fuchs:DraftEditing:ExpiryWarnMinutes", 5);
_warnLead = TimeSpan.FromMinutes(Math.Clamp(warnMinutes, 1, Math.Max(1, (int)cache.IdleTtl.TotalMinutes - 1)));
_interval = TimeSpan.FromSeconds(30);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
await SweepAsync(stoppingToken);
}
catch (OperationCanceledException) { /* shutting down */ }
}
/// <summary>One pass over all live sessions. Internal so it can be driven directly from unit tests.</summary>
internal async Task SweepAsync(CancellationToken cancellationToken)
{
DateTime now = DateTime.UtcNow;
foreach (var session in _cache.Snapshot())
{
TimeSpan idle = now - session.LastAccessUtc;
if (idle >= _cache.IdleTtl)
{
_cache.Remove(session.Token);
_logger.LogInformation("Reminder draft {Token} evicted after {Idle} idle (user={User})",
session.Token, idle, session.UserAccountId);
await _notifier.SignalClosedAsync(session.Token, "expired", cancellationToken);
}
else if (idle >= _cache.IdleTtl - _warnLead && !session.ExpiryWarningSent)
{
session.ExpiryWarningSent = true;
int secondsLeft = (int)Math.Max(0, (_cache.IdleTtl - idle).TotalSeconds);
await _notifier.SignalExpiringAsync(session.Token, secondsLeft, cancellationToken);
}
}
}
}