Changed Reminder Systematics analogue to invoices
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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><p>…</p></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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user