fix(drafts): serialize draft session flushes and register reminders via fds__setReminder
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
Make invoice/reminder live-draft edits concurrency-safe with an explicit DraftFlushResult, add the fds__setReminder stored procedure, and harden the date helper functions. Includes concurrency and SQL contract tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d0002ccdad
commit
546648533b
@@ -0,0 +1,13 @@
|
||||
namespace Fuchs.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a draft flush ("Zwischenspeichern", <see cref="IInvoiceDraftService.FlushToDbAsync"/> /
|
||||
/// <see cref="IReminderDraftService.FlushToDbAsync"/>). The draft service alone decides whether a
|
||||
/// flush creates a DB row or updates the one an earlier flush created — flushes of one session are
|
||||
/// serialised, so a save that overlaps the first registration of a new draft becomes an update — and
|
||||
/// reports that decision here so the caller publishes the matching domain event instead of guessing
|
||||
/// from the session state it saw before the flush.
|
||||
/// </summary>
|
||||
/// <param name="Data">The registered data; its <c>Id</c> is empty when the registration failed.</param>
|
||||
/// <param name="Updated"><c>true</c> when the registration ran as an update of an existing DB draft, <c>false</c> for a create.</param>
|
||||
public sealed record DraftFlushResult<T>(T Data, bool Updated) where T : class;
|
||||
@@ -45,10 +45,13 @@ public interface IInvoiceDraftService
|
||||
|
||||
/// <summary>
|
||||
/// Persists the cached session to the DB via the existing invoice registration path
|
||||
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success.
|
||||
/// Returns the registered invoice data (for the success event), or null if the token is unknown.
|
||||
/// ("Zwischenspeichern"). Sets <see cref="InvoiceDraftSession.InvId"/> on success. Flushes of one
|
||||
/// session are serialised (<see cref="InvoiceDraftSession.FlushGate"/>): an overlapping save of a
|
||||
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||
/// ran as an update (for the success event), or null if the token is unknown.
|
||||
/// </summary>
|
||||
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
Task<DraftFlushResult<FdsInvoiceData>?> 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>
|
||||
Task<Document?> RenderPreviewAsync(string token);
|
||||
|
||||
@@ -43,10 +43,13 @@ public interface IReminderDraftService
|
||||
|
||||
/// <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.
|
||||
/// ("Zwischenspeichern"). Sets <see cref="ReminderDraftSession.RemId"/> on success. Flushes of one
|
||||
/// session are serialised (<see cref="ReminderDraftSession.FlushGate"/>): an overlapping save of a
|
||||
/// not-yet-registered draft waits for the first registration and then runs as an update, so a
|
||||
/// double-click never creates two DB rows. Returns the registered data plus whether the flush
|
||||
/// ran as an update (for the success event), or null if the token is unknown.
|
||||
/// </summary>
|
||||
Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
|
||||
Task<DraftFlushResult<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);
|
||||
|
||||
@@ -60,27 +60,32 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
// The editor fires patches (and state/preview fetches) concurrently for one token; without
|
||||
// the lock a block.replace on one request mutates Req while another enumerates it.
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
_logger.LogDebug("Draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
{
|
||||
_logger.LogDebug("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;
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -424,33 +429,40 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
}
|
||||
|
||||
// ── View state / history ────────────────────────────────────────────────
|
||||
/// <remarks>
|
||||
/// The result is serialised by the caller after this returns, so everything mutable is a
|
||||
/// snapshot taken under the session lock — never a live reference into the session.
|
||||
/// </remarks>
|
||||
public object BuildState(InvoiceDraftSession session)
|
||||
{
|
||||
session.Touch();
|
||||
return new
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
invid = session.InvId,
|
||||
isDraft = session.IsDraft,
|
||||
admin = session.Admin,
|
||||
@new = session.New,
|
||||
req = session.Req,
|
||||
sums = new
|
||||
return new
|
||||
{
|
||||
total_net = session.Sums.TotalNet,
|
||||
total_gross = session.Sums.TotalGross,
|
||||
total_vat = session.Sums.TotalVat,
|
||||
service_net = session.Sums.ServiceNet,
|
||||
service_vat = session.Sums.ServiceVat,
|
||||
vat = session.Sums.VatByRate,
|
||||
block_net = session.Sums.NetByBlock
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session),
|
||||
setDisplay = BuildSetDisplay(session)
|
||||
};
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
invid = session.InvId,
|
||||
isDraft = session.IsDraft,
|
||||
admin = session.Admin.DeepClone(),
|
||||
@new = session.New.DeepClone(),
|
||||
req = session.Req.DeepClone(),
|
||||
sums = new
|
||||
{
|
||||
total_net = session.Sums.TotalNet,
|
||||
total_gross = session.Sums.TotalGross,
|
||||
total_vat = session.Sums.TotalVat,
|
||||
service_net = session.Sums.ServiceNet,
|
||||
service_vat = session.Sums.ServiceVat,
|
||||
vat = new Dictionary<string, decimal>(session.Sums.VatByRate),
|
||||
block_net = new Dictionary<string, decimal>(session.Sums.NetByBlock)
|
||||
},
|
||||
validation = session.ValidationMessages.Select(v => new { field = v.Field, severity = v.Severity, message = v.Message }).ToList(),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session).ToList(),
|
||||
setDisplay = BuildSetDisplay(session)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -497,34 +509,60 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
|
||||
.Select(n => new { text = n.Text, style = n.Style });
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||
lock (session.SyncRoot) return session.History.ToList();
|
||||
}
|
||||
|
||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||
public async Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
public async Task<DraftFlushResult<FdsInvoiceData>?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
var fds = BuildFdsData(session);
|
||||
bool change = !string.IsNullOrEmpty(session.InvId);
|
||||
var reg = await _invoices.RegisterInvoiceAsync(fds, change, session.InvId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
// One flush at a time per session: the create/update decision below is read-then-write across
|
||||
// an await, so two overlapping saves of a new draft would both see an empty InvId and both
|
||||
// call fds__createInvoice. The second save waits here, then runs as an update of the row the
|
||||
// first one created (with its own, newer snapshot).
|
||||
await session.FlushGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
session.InvId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
FdsInvoiceData fds;
|
||||
string invId;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildFdsData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||
invId = session.InvId;
|
||||
}
|
||||
bool change = !string.IsNullOrEmpty(invId);
|
||||
var reg = await _invoices.RegisterInvoiceAsync(fds, change, invId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
{
|
||||
lock (session.SyncRoot) session.InvId = reg.Id;
|
||||
_cache.Set(session);
|
||||
_logger.LogInformation("Draft {Token} flushed to DB invoice {InvId} (change={Change}, user={User})",
|
||||
token, reg.Id, change, userAccountId);
|
||||
}
|
||||
return new DraftFlushResult<FdsInvoiceData>(reg, change);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.FlushGate.Release();
|
||||
}
|
||||
return reg;
|
||||
}
|
||||
|
||||
public async Task<Document?> RenderPreviewAsync(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
var fds = BuildFdsData(session);
|
||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||
FdsInvoiceData fds;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildFdsData(session);
|
||||
fds.InvoiceRegistration = SynthesizeRegistration(session);
|
||||
}
|
||||
fds.IsDraft = true;
|
||||
return await _invoices.GenerateInvoicePdfAsync(fds, draft: true);
|
||||
}
|
||||
|
||||
@@ -59,27 +59,30 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return null;
|
||||
|
||||
string oldValue = "", newValue = "";
|
||||
bool mutated = ApplyDelta(session, delta, ref oldValue, ref newValue);
|
||||
if (!mutated)
|
||||
lock (session.SyncRoot) // concurrent patch/state/preview requests for one token must not interleave
|
||||
{
|
||||
_logger.LogDebug("Reminder draft {Token}: no-op patch target={Target} ref={Ref}", token, delta.Target, delta.Ref);
|
||||
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;
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -144,24 +147,27 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
public object BuildState(ReminderDraftSession session)
|
||||
{
|
||||
session.Touch();
|
||||
return new
|
||||
lock (session.SyncRoot) // serialised by the caller later — hand out snapshots, not live references
|
||||
{
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
remid = session.RemId,
|
||||
isDraft = session.IsDraft,
|
||||
@new = session.New,
|
||||
rem = session.Rem,
|
||||
sums = new
|
||||
return 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,
|
||||
notes = BuildNotes(session)
|
||||
};
|
||||
token = session.Token,
|
||||
version = session.Version,
|
||||
remid = session.RemId,
|
||||
isDraft = session.IsDraft,
|
||||
@new = session.New.DeepClone(),
|
||||
rem = session.Rem.DeepClone(),
|
||||
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 }).ToList(),
|
||||
historyCount = session.History.Count,
|
||||
notes = BuildNotes(session)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -177,34 +183,58 @@ public sealed class ReminderDraftEditService : IReminderDraftService
|
||||
return new { intro, closing };
|
||||
}
|
||||
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token) =>
|
||||
_cache.Get(token)?.History ?? (IReadOnlyList<ChangeHistoryEntry>)Array.Empty<ChangeHistoryEntry>();
|
||||
public IReadOnlyList<ChangeHistoryEntry> GetHistory(string token)
|
||||
{
|
||||
var session = _cache.Get(token);
|
||||
if (session == null) return Array.Empty<ChangeHistoryEntry>();
|
||||
lock (session.SyncRoot) return session.History.ToList();
|
||||
}
|
||||
|
||||
// ── Flush / preview ────────────────────────────────────────────────────────
|
||||
public async Task<FdsReminderData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec)
|
||||
public async Task<DraftFlushResult<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))
|
||||
// One flush at a time per session (see InvoiceDraftEditService.FlushToDbAsync): two overlapping
|
||||
// saves of a new draft would otherwise both see an empty RemId and both call fds__createReminder.
|
||||
await session.FlushGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
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);
|
||||
FdsReminderData fds;
|
||||
string remId;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildReminderData(session); // deep-cloned snapshot — the await below runs outside the lock
|
||||
remId = session.RemId;
|
||||
}
|
||||
bool change = !string.IsNullOrEmpty(remId);
|
||||
var reg = await _reminders.RegisterReminderAsync(fds, change, remId, userAccountId, dbSec);
|
||||
if (!string.IsNullOrEmpty(reg.Id))
|
||||
{
|
||||
lock (session.SyncRoot) 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 new DraftFlushResult<FdsReminderData>(reg, change);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.FlushGate.Release();
|
||||
}
|
||||
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);
|
||||
FdsReminderData fds;
|
||||
lock (session.SyncRoot)
|
||||
{
|
||||
fds = BuildReminderData(session);
|
||||
fds.ReminderRegistration = SynthesizeRegistration(session);
|
||||
}
|
||||
fds.IsDraft = true;
|
||||
return _reminders.GenerateReminderPdf(fds, draft: true);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,25 @@ public class ReminderService : IReminderService
|
||||
return rem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The save batch for <see cref="RegisterReminderAsync"/>: <c>fds__createReminder</c> for a new
|
||||
/// draft, <c>fds__setReminder</c> (update in place, same result row) for a re-saved one. Either
|
||||
/// proc leaves <c>@Id</c> NULL when it did not save — the set proc refuses a finalised reminder
|
||||
/// or one with a stored file — so the guard turns that into an error instead of an empty result
|
||||
/// row the caller would read as "saved".
|
||||
/// </summary>
|
||||
internal static string BuildRegisterReminderSql(bool change)
|
||||
{
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
sqlParts.Add(change
|
||||
? "EXECUTE [dbo].[fds__setReminder] @RemId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;"
|
||||
: "EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||
sqlParts.Add(change
|
||||
? "IF @Id IS NULL THROW 50001, N'fds__setReminder did not return a reminder id - reminder draft not saved (finalised, has a file, or unknown).', 1;"
|
||||
: "IF @Id IS NULL THROW 50001, N'fds__createReminder did not return a reminder id - reminder not saved.', 1;");
|
||||
return string.Join("\n", sqlParts);
|
||||
}
|
||||
|
||||
public async Task<FdsReminderData> RegisterReminderAsync(FdsReminderData reminder, bool change, string remId,
|
||||
string userAccountId, DatabaseSecurity dbSec)
|
||||
{
|
||||
@@ -82,20 +101,17 @@ public class ReminderService : IReminderService
|
||||
SQL_NVarChar("text", reminder.NewValues?.nz("text") ?? "", dbNull_IfEmpty: true)
|
||||
};
|
||||
|
||||
var sqlParts = new List<string> { "DECLARE @Id varchar(10);" };
|
||||
if (!change || string.IsNullOrEmpty(remId))
|
||||
sqlParts.Add("EXECUTE [dbo].[fds__createReminder] @InvId, @type, @amount, @amount_payed, @SendToAddress, @SendToEmail, @subject, @text, @authuser, @Id OUTPUT;");
|
||||
else
|
||||
pl.Add(SQL_VarChar("RemId", remId));
|
||||
change = change && !string.IsNullOrEmpty(remId);
|
||||
if (change) pl.Add(SQL_VarChar("RemId", remId));
|
||||
|
||||
var remdset = await getSQLDataSet_async(string.Join("\n", sqlParts),
|
||||
var remdset = await getSQLDataSet_async(BuildRegisterReminderSql(change),
|
||||
Conn, pl, tablenames: new[] { "rem" },
|
||||
Security: dbSec, options: new FIS_SQLOptions());
|
||||
if (!string.IsNullOrEmpty(remdset.Exception))
|
||||
_logger.LogError("RegisterReminderAsync sql exception: {Ex}", remdset.Exception);
|
||||
|
||||
reminder.ReminderRegistration = new GenericObjectDictionary(remdset.Table("rem").FirstRow.toObjectDictionary());
|
||||
_logger.LogInformation("RegisterReminderAsync registered id={Id}", reminder.Id);
|
||||
_logger.LogInformation("RegisterReminderAsync registered id={Id} (change={Change})", reminder.Id, change);
|
||||
return reminder;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user