Enhance logging in FdsSqlOptions and related classes
- Updated FdsSqlOptions to accept an optional ILogger parameter for improved error logging. - Modified FdsMfr and FdsMfrClient classes to pass the logger instance to FdsSqlOptions. - Added detailed error logging in various methods to capture SQL execution issues and file handling errors. - Improved documentation for FdsSqlOptions to clarify logging behavior. - Updated Archive class to log compression errors, enhancing traceability of failures. - Adjusted project configuration to suppress specific warnings related to transitive dependencies. - Added NuGet.config to define package sources for dependency management. - Updated submodule references for OCORE and related projects.
This commit is contained in:
@@ -22,8 +22,9 @@ public partial class IntranetController
|
||||
return await JSONAsync(new { manage = 1 });
|
||||
|
||||
case "up":
|
||||
_logger.LogInformation("Banking MT940 upload: {FileCount} file(s) user={User}",
|
||||
_logger.LogInformation("Banking statement upload: {FileCount} file(s) user={User}",
|
||||
Request.Form.Files.Count, UserAccountID);
|
||||
var uploadResults = new List<object>();
|
||||
foreach (var fle in Request.Form.Files)
|
||||
{
|
||||
using var stream = fle.OpenReadStream();
|
||||
@@ -34,6 +35,9 @@ public partial class IntranetController
|
||||
|
||||
var tbl = _banking.ParseToDatatable(stream, schemaDt);
|
||||
var tmptbl = "bs_" + Guid.NewGuid().ToString().Replace("-", "");
|
||||
var (importFrom, importTo) = BankingDateRange(tbl);
|
||||
bool importFailed = false;
|
||||
string importFailure = "";
|
||||
|
||||
var dtwa = new DatatableWriterAsync(tbl, _intranet.Intranet__SQLConnectionString)
|
||||
{
|
||||
@@ -48,16 +52,80 @@ public partial class IntranetController
|
||||
dtwa.CommandAfterError = new SqlCommand(
|
||||
$"SELECT * INTO [{tmptbl}] FROM {dtwa.DestinationTableName};");
|
||||
dtwa.OnError += (_, exc, _) =>
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
"Banking upload SQL exception — file={File} destTable={DestTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - sql exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
dtwa.OnCommandAfterError += (_, exc) =>
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = exc.Message;
|
||||
_logger.LogError(exc,
|
||||
"Banking upload merge-command exception — file={File} destTable={DestTable} " +
|
||||
"rescueTable={RescueTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, tmptbl, UserAccountID);
|
||||
_intranet.debug_log("IntranetController.bam.up - command-after exception",
|
||||
exc, UserAccountID, new { uid = dtwa.InstanceGUID, tmptbl });
|
||||
};
|
||||
_logger.LogDebug("Banking upload parsed {Rows} rows → temp table submit (user={User})",
|
||||
tbl.Rows.Count, UserAccountID);
|
||||
dtwa.DoSubmit();
|
||||
if (dtwa.SubmitException != null)
|
||||
{
|
||||
importFailed = true;
|
||||
importFailure = dtwa.SubmitException.Message;
|
||||
_logger.LogError(dtwa.SubmitException,
|
||||
"Banking upload submit exception — file={File} destTable={DestTable} user={User}",
|
||||
fle.FileName, dtwa.DestinationTableName, UserAccountID);
|
||||
}
|
||||
|
||||
if (importFailed)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Banking import failed — file={File} rows={Rows} reason={Reason} user={User}",
|
||||
fle.FileName, tbl.Rows.Count, importFailure, UserAccountID);
|
||||
await _events.BankingImportIssueAsync(
|
||||
$"Kontobewegungen aus {fle.FileName} konnten nicht importiert werden: {importFailure}",
|
||||
fle.FileName, UserAccountID);
|
||||
}
|
||||
else if (tbl.Rows.Count == 0)
|
||||
{
|
||||
// Parsing produced zero rows — check the preceding "Bank statement parsed"
|
||||
// warning from BankingService for the reason (missing account element,
|
||||
// unsupported schema variant, empty file, ...).
|
||||
_logger.LogWarning(
|
||||
"Banking import: 0 rows parsed from {File} — nothing to import. user={User}",
|
||||
fle.FileName, UserAccountID);
|
||||
await _events.BankingImportIssueAsync(
|
||||
$"Aus {fle.FileName} konnten keine Kontobewegungen importiert werden.",
|
||||
fle.FileName, UserAccountID);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Banking import succeeded — file={File} rows={Rows} from={From} to={To} user={User}",
|
||||
fle.FileName, tbl.Rows.Count, importFrom, importTo, UserAccountID);
|
||||
await _events.BankingTransactionsImportedAsync(
|
||||
importFrom, importTo, tbl.Rows.Count, fle.FileName, UserAccountID);
|
||||
}
|
||||
|
||||
uploadResults.Add(new
|
||||
{
|
||||
fileName = fle.FileName,
|
||||
rows = tbl.Rows.Count,
|
||||
success = !importFailed && tbl.Rows.Count > 0,
|
||||
error = importFailed ? importFailure : ""
|
||||
});
|
||||
}
|
||||
return Ok();
|
||||
// Return a JSON body: the frontend posts with dataType 'json', so an
|
||||
// empty 200 would be reported as a parse error and surface the generic
|
||||
// "auth failed" alert even though the import actually succeeded.
|
||||
return await JSONAsync(new { ok = true, files = uploadResults });
|
||||
|
||||
case "qtl":
|
||||
{
|
||||
@@ -126,7 +194,9 @@ public partial class IntranetController
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_done] @taID, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" });
|
||||
return res.Result is true
|
||||
? await JSONAsync(new { ok = true })
|
||||
: StatusCode(500, new { error = "not successful" });
|
||||
}
|
||||
|
||||
case "ati":
|
||||
@@ -139,7 +209,9 @@ public partial class IntranetController
|
||||
"EXECUTE [dbo].[fds__setBankingtransaction_assignToIvoice] @taID, @invoice_id, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
return res.Result is true ? Ok() : StatusCode(500, new { error = "not successful" });
|
||||
return res.Result is true
|
||||
? await JSONAsync(new { ok = true })
|
||||
: StatusCode(500, new { error = "not successful" });
|
||||
}
|
||||
|
||||
case "vfi":
|
||||
@@ -165,4 +237,26 @@ public partial class IntranetController
|
||||
|
||||
protected string Form(string key, string fallback = "") =>
|
||||
Request.Form.TryGetValue(key, out var v) ? v.ToString() : fallback;
|
||||
|
||||
private static (DateTime? From, DateTime? To) BankingDateRange(System.Data.DataTable tbl)
|
||||
{
|
||||
DateTime? from = null;
|
||||
DateTime? to = null;
|
||||
foreach (System.Data.DataRow row in tbl.Rows)
|
||||
{
|
||||
DateTime? date = BankingRowDate(row, tbl.Columns.Contains("EntryDate") ? "EntryDate" : "")
|
||||
?? BankingRowDate(row, tbl.Columns.Contains("ValueDate") ? "ValueDate" : "");
|
||||
if (date == null) continue;
|
||||
from = from == null || date.Value < from.Value ? date.Value : from;
|
||||
to = to == null || date.Value > to.Value ? date.Value : to;
|
||||
}
|
||||
return (from, to);
|
||||
}
|
||||
|
||||
private static DateTime? BankingRowDate(System.Data.DataRow row, string column)
|
||||
{
|
||||
if (string.IsNullOrEmpty(column) || row[column] == DBNull.Value) return null;
|
||||
if (row[column] is DateTime dt) return dt.Date;
|
||||
return DateTime.TryParse(row[column]?.ToString(), out var parsed) ? parsed.Date : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,14 @@ public partial class IntranetController
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (!string.IsNullOrEmpty(dt2.Exception))
|
||||
{
|
||||
_logger.LogError("sis: SQL error for invoice {InvoiceId}: {SqlError}, user={User}", invoiceId, dt2.Exception, UserAccountID);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"Rechnung {invoiceId} konnte nicht als versandt markiert werden.",
|
||||
UserAccountID, invoiceId);
|
||||
}
|
||||
else
|
||||
await _events.InvoiceMarkedSentAsync(invoiceId, invoiceId, UserAccountID);
|
||||
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,10 +43,11 @@ public partial class IntranetController
|
||||
new FdsReminderData(ctd), change: false, remId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdRem.Id))
|
||||
{
|
||||
await _events.ReminderDraftCreatedAsync(fdRem, UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_reminders.GenerateReminderPdf(fdRem, fdRem.IsDraft));
|
||||
return await JSONAsync(new { id = fdRem.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return StatusCode(500, new { error = "Erinnerung wurde nicht registriert" });
|
||||
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
case "conf": return await HandleReminderConf(fn, id, code);
|
||||
@@ -59,6 +60,12 @@ public partial class IntranetController
|
||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pl,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
if (string.IsNullOrEmpty(dt2.Exception))
|
||||
await _events.ReminderMarkedSentAsync(Form("id"), Form("id"), UserAccountID);
|
||||
else
|
||||
await _events.ReminderIssueAsync(
|
||||
$"Mahnung {Form("id")} konnte nicht als versandt markiert werden.",
|
||||
UserAccountID, Form("id"));
|
||||
return string.IsNullOrEmpty(dt2.Exception) ? Ok() : StatusCode(500);
|
||||
}
|
||||
|
||||
@@ -127,16 +134,35 @@ public partial class IntranetController
|
||||
email.Trim(), "", remdoc);
|
||||
if (sent)
|
||||
{
|
||||
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID);
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", remId), SQL_Bit("@auto", true));
|
||||
await getSQLDatatable_async(
|
||||
"EXECUTE [dbo].[fds__setReminderSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pls,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Reminder email send failed — reminderId={ReminderId} email={Email} user={User}",
|
||||
remId, email.Trim(), UserAccountID);
|
||||
await _events.ReminderIssueAsync(
|
||||
$"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht an {email.Trim()} versandt werden.",
|
||||
UserAccountID, remId);
|
||||
}
|
||||
}
|
||||
else if (filebyte.Length == 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Reminder PDF render returned 0 bytes — reminderId={ReminderId} user={User}",
|
||||
remId, UserAccountID);
|
||||
await _events.ReminderIssueAsync(
|
||||
$"Die Mahn-PDF {frdic.nz("DocumentName", "").ne($"Zahlungserinnerung_{remId}.pdf")} konnte nicht erstellt werden.",
|
||||
UserAccountID, remId);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleReminderIdoc(string fn, string id, string code)
|
||||
@@ -178,14 +204,39 @@ public partial class IntranetController
|
||||
if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) &&
|
||||
frdic.no("InvoiceFile", null!) is byte[] invFile)
|
||||
remdoc[frdic.nz("InvoiceFileName")] = invFile;
|
||||
await _comService.SendEmailAsync($"rem_{remId}",
|
||||
bool sent = await _comService.SendEmailAsync($"rem_{remId}",
|
||||
$"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}",
|
||||
BuildReminderBody(Convert.ToDouble(frdic.no("amount_open", 0))),
|
||||
email.Trim(), "", remdoc);
|
||||
if (sent)
|
||||
{
|
||||
var fdRem = await _reminders.LoadReminderAsync(remId, UserAccountID, DbSec);
|
||||
await _events.ReminderSentToCustomerAsync(fdRem, email.Trim(), UserAccountID, resent: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Reminder resend email send failed — reminderId={ReminderId} email={Email} user={User}",
|
||||
remId, email.Trim(), UserAccountID);
|
||||
await _events.ReminderIssueAsync(
|
||||
$"Mahnung {frdic.nz("subject").ne(remId)} konnte nicht erneut an {email.Trim()} versandt werden.",
|
||||
UserAccountID, remId);
|
||||
}
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
return await ReminderIssueResult("Die Mahnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ReminderIssueResult(string message, string reminderId = "")
|
||||
{
|
||||
// Mirrors the SignalR toast in a durable app log: without this, a reminder
|
||||
// save/create/send failure was only visible as a GUI notification nobody was
|
||||
// necessarily watching at the time.
|
||||
_logger.LogError("Reminder issue — reminderId={ReminderId} user={User} message={Message}",
|
||||
reminderId, UserAccountID, message);
|
||||
await _events.ReminderIssueAsync(message, UserAccountID, reminderId);
|
||||
return StatusCode(500, new { error = message });
|
||||
}
|
||||
|
||||
private static string BuildReminderBody(double amountOpen) =>
|
||||
|
||||
@@ -36,7 +36,15 @@ public partial class IntranetController
|
||||
ri["params"] = dset.Tables("params")
|
||||
.toArrayofObjectDictionaries($"[object_id] = {ri["object_id"]} AND [name] <> '@authuser'");
|
||||
}
|
||||
catch { ri["params"] = Array.Empty<Dictionary<string, object>>(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Without this, a genuinely broken params filter/query is indistinguishable
|
||||
// from the expected "this report has no params" case in the response.
|
||||
_logger.LogWarning(ex,
|
||||
"Report catalog: failed to load params for object_id={ObjectId} user={User}",
|
||||
ri["object_id"], UserAccountID);
|
||||
ri["params"] = Array.Empty<Dictionary<string, object>>();
|
||||
}
|
||||
}
|
||||
return await JSONAsync(new
|
||||
{
|
||||
|
||||
@@ -49,9 +49,11 @@ public partial class IntranetController
|
||||
var fdInv = await _invoices.RegisterInvoiceAsync(
|
||||
new FdsInvoiceData(JsonConvert.DeserializeObject(Form("invc"))!),
|
||||
change: !string.IsNullOrEmpty(Form("id")), invId: Form("id"), UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, !string.IsNullOrEmpty(Form("id")), UserAccountID);
|
||||
return !string.IsNullOrEmpty(fdInv.Id)
|
||||
? await JSONAsync(new { id = fdInv.Id })
|
||||
: StatusCode(500, new { error = "Rechnung wurde nicht gespeichert" });
|
||||
: await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht gespeichert werden.");
|
||||
}
|
||||
|
||||
case "sprep":
|
||||
@@ -62,10 +64,11 @@ public partial class IntranetController
|
||||
change: false, invId: "", UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
case "sedit":
|
||||
@@ -76,10 +79,11 @@ public partial class IntranetController
|
||||
change: true, invId: Form("id"), UserAccountID, DbSec);
|
||||
if (!string.IsNullOrEmpty(fdInv.Id))
|
||||
{
|
||||
await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID);
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
}
|
||||
return StatusCode(500, new { error = "Rechnung wurde nicht registriert" });
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden.");
|
||||
}
|
||||
|
||||
case "sdel":
|
||||
@@ -141,13 +145,20 @@ public partial class IntranetController
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> AttachReports(SQLDataSet dset)
|
||||
private List<Dictionary<string, object?>> AttachReports(SQLDataSet dset)
|
||||
{
|
||||
var req = new List<Dictionary<string, object?>>(dset.Tables("requests").toArrayofObjectDictionaries()!);
|
||||
foreach (var r in req)
|
||||
{
|
||||
try { r["reports"] = dset.Tables("reports").toArrayofObjectDictionaries($"[requestID] = {r["Id"]}"); }
|
||||
catch { /* no reports table */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// "reports" table absent is expected for some queries; but a real failure while
|
||||
// joining (e.g. malformed filter) looked identical to that with no way to tell them apart.
|
||||
_logger.LogWarning(ex,
|
||||
"AttachReports: failed to join reports for requestId={RequestId} user={User}",
|
||||
r["Id"], UserAccountID);
|
||||
}
|
||||
}
|
||||
return req;
|
||||
}
|
||||
@@ -285,15 +296,34 @@ public partial class IntranetController
|
||||
body, email.Trim(), "", inv);
|
||||
if (sent)
|
||||
{
|
||||
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID);
|
||||
var pls = StdParamlist(SQL_VarChar("@Id", invId), SQL_Bit("@auto", true));
|
||||
await getSQLDatatable_async("EXECUTE [dbo].[fds__setInvoiceSent] @Id, @auto, @authuser;",
|
||||
_intranet.Intranet__SQLConnectionString, pls,
|
||||
Security: DbSec, options: SqlOpt(fn, id, code));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Invoice email send failed — invoiceId={InvoiceId} email={Email} user={User}",
|
||||
invId, email.Trim(), UserAccountID);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht an {email.Trim()} versandt werden.",
|
||||
UserAccountID, invId);
|
||||
}
|
||||
}
|
||||
else if (filebyte.Length == 0)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Invoice PDF render returned 0 bytes — invoiceId={InvoiceId} user={User}",
|
||||
invId, UserAccountID);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"Die Rechnungs-PDF {frdic.nz("DocumentName").ne($"Rechnung_{invId}.pdf")} konnte nicht erstellt werden.",
|
||||
UserAccountID, invId);
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden.");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> HandleRequestIdoc(string fn, string id, string code)
|
||||
@@ -309,7 +339,7 @@ public partial class IntranetController
|
||||
: _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return ct != null
|
||||
? await FileContentResultAsync(ct, "application/pdf", filename, inline: true)
|
||||
: StatusCode(500, new { error = "Rechnungs-PDF konnte nicht erstellt werden" });
|
||||
: await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id);
|
||||
}
|
||||
var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft));
|
||||
return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages });
|
||||
@@ -335,14 +365,35 @@ public partial class IntranetController
|
||||
{
|
||||
double bal = Convert.ToDouble(frdic.no("InvoiceBalance", 0));
|
||||
string terms = fdInv.PaymentTerms.Replace("wd", " Werktagen").Replace("d", " Tagen").Replace("wk", " Wochen").ne("10 Tagen");
|
||||
await _comService.SendEmailAsync(
|
||||
bool sent = await _comService.SendEmailAsync(
|
||||
$"inv_{invId}", $"Sanit\u00e4rFuchs - {frdic.nz("DocumentName")}",
|
||||
BuildInvoiceBody(bal, terms), email.Trim(), "",
|
||||
new Dictionary<string, byte[]> { [frdic.nz("DocumentName")] = filebyte });
|
||||
if (sent)
|
||||
await _events.InvoiceSentToCustomerAsync(fdInv, email.Trim(), UserAccountID, resent: true);
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Invoice resend email send failed — invoiceId={InvoiceId} email={Email} user={User}",
|
||||
invId, email.Trim(), UserAccountID);
|
||||
await _events.InvoiceIssueAsync(
|
||||
$"Rechnung {frdic.nz("InvoiceId").ne(invId)} konnte nicht erneut an {email.Trim()} versandt werden.",
|
||||
UserAccountID, invId);
|
||||
}
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
return StatusCode(500, new { error = "Aktion war nicht erfolgreich" });
|
||||
return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht versandt werden.");
|
||||
}
|
||||
|
||||
private async Task<IActionResult> InvoiceIssueResult(string message, string invoiceId = "")
|
||||
{
|
||||
// Mirrors the SignalR toast in a durable app log: without this, an invoice save/create/send
|
||||
// failure was only visible as a GUI notification nobody was necessarily watching at the time.
|
||||
_logger.LogError("Invoice issue — invoiceId={InvoiceId} user={User} message={Message}",
|
||||
invoiceId, UserAccountID, message);
|
||||
await _events.InvoiceIssueAsync(message, UserAccountID, invoiceId);
|
||||
return StatusCode(500, new { error = message });
|
||||
}
|
||||
|
||||
private static string BuildInvoiceBody(double balance, string paymentTerms) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Web;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -33,6 +34,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
private readonly IReportService _reports;
|
||||
private readonly IInvoiceService _invoices;
|
||||
private readonly IReminderService _reminders;
|
||||
private readonly IEventService _events;
|
||||
private readonly List<string> _allowedNonAuth = new() { "spwc", "spw" };
|
||||
private readonly List<string> _allowedGet = new()
|
||||
{
|
||||
@@ -59,7 +61,8 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
IWidgetService widgets,
|
||||
IReportService reports,
|
||||
IInvoiceService invoices,
|
||||
IReminderService reminders)
|
||||
IReminderService reminders,
|
||||
IEventService events)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_mfr = mfr;
|
||||
@@ -72,6 +75,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
_reports = reports;
|
||||
_invoices = invoices;
|
||||
_reminders = reminders;
|
||||
_events = events;
|
||||
}
|
||||
|
||||
/// <summary>Merged query-string + form parameters (form wins) for report processing.</summary>
|
||||
@@ -102,7 +106,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller
|
||||
public DatabaseSecurity DbSec => _intranet.GetDbSecurity(UserAccountID);
|
||||
|
||||
public FIS_SQLOptions SqlOpt(string fn, string id, string code) =>
|
||||
new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code });
|
||||
new(new Dictionary<string, object> { ["fn"] = fn, ["id"] = id, ["code"] = code }, _logger);
|
||||
|
||||
// ── Action helpers ────────────────────────────────────────────────────────
|
||||
protected IActionResult Unauthorized401() => StatusCode(401);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Concepts
|
||||
|
||||
This folder holds **living design write-ups** of how a subsystem currently
|
||||
works: its moving parts, data flow, and how they fit together. Unlike
|
||||
[`../Decisions`](../Decisions/README.md), concept docs are **not** immutable
|
||||
— keep them in sync with the implementation as it evolves.
|
||||
|
||||
## What belongs here
|
||||
|
||||
"How does the notification pipeline work end to end" is a concept doc. "Why
|
||||
did we choose SignalR over polling for it" is a decision. A single feature
|
||||
area typically has one concept doc and may reference several decisions that
|
||||
shaped it.
|
||||
|
||||
## File naming
|
||||
|
||||
`kebab-case-topic.md` (no numbering — concepts aren't sequential events).
|
||||
|
||||
## Required YAML frontmatter
|
||||
|
||||
```yaml
|
||||
---
|
||||
status: Active # Active | Deprecated
|
||||
lastUpdated: 2026-07-03
|
||||
applyTo: # glob(s) — files/areas this concept describes
|
||||
- "Fuchs/Notifications/**"
|
||||
relatedDecisions: # filenames in ../Decisions this concept implements
|
||||
- "0001-domain-events-and-notification-triggers.md"
|
||||
---
|
||||
```
|
||||
|
||||
**Agents must scan the YAML frontmatter of every file in this folder first**
|
||||
and only read the full body of concepts whose `applyTo` glob matches the
|
||||
files they're about to touch, or whose subject is otherwise clearly relevant.
|
||||
|
||||
## Body template
|
||||
|
||||
```markdown
|
||||
# Topic
|
||||
|
||||
## Summary
|
||||
One paragraph: what this subsystem does and why it exists.
|
||||
|
||||
## How it works
|
||||
The mechanics — components, data flow, sequencing. Diagrams (ASCII/mermaid)
|
||||
welcome where they clarify.
|
||||
|
||||
## Key files
|
||||
Bullet list of the primary files/classes involved.
|
||||
|
||||
## Related decisions
|
||||
Links to the ADRs in `../Decisions` that shaped this design.
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Keep concepts current.** When you materially change how a documented
|
||||
subsystem works, update its concept doc in the same change — don't let it
|
||||
drift from the code.
|
||||
- **Create a concept doc for new non-trivial subsystems.** If you build
|
||||
something a future agent would need a paragraph of context to safely
|
||||
modify, write that paragraph here instead of making them re-derive it from
|
||||
the diff.
|
||||
- Concepts describe **current** behavior. If something changes, edit the
|
||||
doc in place — don't append a changelog inside it (git history is the
|
||||
changelog).
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-03
|
||||
applyTo:
|
||||
- "Fuchs/Notifications/**"
|
||||
- "Fuchs/Services/**"
|
||||
- "Fuchs/Controllers/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0001 — Domain events (success and failure) trigger user-understandable notifications
|
||||
|
||||
## Context
|
||||
Business operations (invoice creation, sending, marking sent, reminders,
|
||||
banking import) happen server-side, often outside a synchronous request the
|
||||
user is watching (background jobs, long-running sends). Users had no
|
||||
reliable way to learn that an operation they cared about — or one that
|
||||
failed — actually happened, short of refreshing lists or checking logs.
|
||||
|
||||
## Decision
|
||||
Every meaningful business outcome, success **and** failure, is modeled as a
|
||||
`DomainEvent` (`Fuchs/Notifications/DomainEvent.cs`) with:
|
||||
- a `DomainEventType` enum value identifying what happened,
|
||||
- the acting `UserAccountId`,
|
||||
- a `Title`, and
|
||||
- a `Context` dictionary of the data needed to render a human-readable
|
||||
message (invoice number, email address, file name, row counts, etc.).
|
||||
|
||||
Services call the corresponding method on `IEventService`
|
||||
(`Fuchs/Notifications/IEventService.cs`, implemented by `EventService`)
|
||||
at the point the outcome is known — e.g.
|
||||
`InvoiceSentToCustomerAsync(invoice, email, userAccountId)` or
|
||||
`InvoiceIssueAsync(message, userAccountId, invoiceId)` on failure.
|
||||
`EventService.PublishAsync` renders the event into a `GuiNotification` with a
|
||||
German, end-user-readable `Message` (e.g. *"Rechnung R2026-0001 wurde an den
|
||||
Kunden mit der E-Mail test@test.de versandt."*) and pushes it — see
|
||||
[0002](0002-gui-notification-delivery-signalr.md) for delivery.
|
||||
|
||||
Every new business operation with a user-visible outcome (created, sent,
|
||||
failed, imported, etc.) must add a `DomainEventType` value and a matching
|
||||
`IEventService` method, and call it from the service at the point of success
|
||||
**and** the point of failure.
|
||||
|
||||
## Consequences
|
||||
- `IEventService` is injected into services that perform user-facing
|
||||
operations (`InvoiceService`, `ReminderService`, `BankingService` callers)
|
||||
— never bypass it by writing directly to `NotificationHub`.
|
||||
- Failure paths must call the `*IssueAsync`/`*Failed` event too, not just
|
||||
succeed-path events — silent failures are the problem this exists to
|
||||
prevent.
|
||||
- Messages are built server-side in `EventService.BuildNotification`, in
|
||||
German, using only `Context` values — keep `Context` populated with
|
||||
everything the message needs (don't rely on the client to look anything
|
||||
up).
|
||||
- Adding a new event type means updating the enum, the `IEventService`
|
||||
interface + `EventService` implementation (trigger method + message
|
||||
branch + `IsFailure` if it's a failure type), and the calling service —
|
||||
in the same change.
|
||||
|
||||
## Alternatives considered
|
||||
- **Polling a status endpoint from the client**: rejected — adds latency,
|
||||
extra load, and doesn't generalize to background/multi-tab flows as
|
||||
cleanly as a push model.
|
||||
- **Raw exception messages surfaced to the GUI**: rejected — not
|
||||
user-understandable and leaks internal details; `Context` + a rendered
|
||||
German message keeps the boundary between internal errors and
|
||||
user-facing text explicit.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-03
|
||||
applyTo:
|
||||
- "Fuchs/Notifications/**"
|
||||
- "Fuchs/js/intranet/**"
|
||||
- "Fuchs/wwwroot/web/**"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0002 — Backend notifications reach the GUI via a SignalR push to every logged-in session
|
||||
|
||||
## Context
|
||||
Domain events (see [0001](0001-domain-events-and-notification-triggers.md))
|
||||
need to reach whichever browser session(s) a user has open, in near
|
||||
real time, without the client polling.
|
||||
|
||||
## Decision
|
||||
- `NotificationHub` (`Fuchs/Notifications/NotificationHub.cs`) is an
|
||||
`[Authorize]` SignalR `Hub` mapped at `/notifications` in `Program.cs`
|
||||
(`app.MapHub<NotificationHub>("/notifications")`).
|
||||
- `EventService.PublishAsync` sends every `GuiNotification` to
|
||||
`_hub.Clients.All.SendAsync("notification", notification, ...)`. Delivery
|
||||
is currently broadcast to all connected (authenticated) clients, not
|
||||
targeted per-user — any logged-in session receives every notification.
|
||||
- Publish failures are caught and logged (`_logger.LogWarning`) rather than
|
||||
thrown — a notification-delivery failure must never fail the underlying
|
||||
business operation that triggered it.
|
||||
- On the client, `$fis.notifications` (`Fuchs/js/intranet/fis_main.js`)
|
||||
opens the SignalR connection once a logged-in `useraccount_id` is known,
|
||||
listens for the `"notification"` event, and calls `push(notification)`
|
||||
to render a dismissible toast into `#notification_frame`. The toast is
|
||||
styled by `notification.severity` (`"error"` vs `"info"`), giving failures
|
||||
a distinct highlighted appearance from successes.
|
||||
- `GuiNotification.Severity` is derived by `EventService.IsFailure` from the
|
||||
`DomainEventType` — failure event types render as `"error"`, everything
|
||||
else as `"info"`.
|
||||
|
||||
## Consequences
|
||||
- Any new `DomainEventType` that represents a failure must be added to
|
||||
`EventService.IsFailure` or it will render as a plain info toast instead
|
||||
of being visually flagged.
|
||||
- Because delivery is broadcast (not user-scoped), notifications are not a
|
||||
substitute for private/sensitive data — `Context`/`Message` content must
|
||||
stay appropriate for any logged-in user to see. If per-user targeting
|
||||
becomes necessary, that is a new decision (SignalR groups keyed by user
|
||||
ID), not a silent change to this one.
|
||||
- The hub requires authentication; unauthenticated sessions never connect
|
||||
and never receive notifications.
|
||||
- Frontend rendering logic lives in `fis_main.js`/`fis.js` — keep the built
|
||||
`wwwroot/web/fis.js`/`fis.min.js` in sync via the gulp build (see
|
||||
`CLAUDE.md` Build & Test) whenever the notification client code changes.
|
||||
|
||||
## Alternatives considered
|
||||
- **Per-user SignalR groups**: more correct long-term but adds group
|
||||
join/leave lifecycle management; deferred until a concrete need for
|
||||
private notifications arises.
|
||||
- **Server-Sent Events / long polling**: rejected — SignalR was already the
|
||||
chosen real-time transport and needs no extra infrastructure.
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-03
|
||||
applyTo:
|
||||
- "Fuchs/Logging/**"
|
||||
- "Fuchs/Program.cs"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0003 — The solution is equipped with structured diagnostic logging
|
||||
|
||||
## Context
|
||||
Diagnosing issues in a deployed intranet instance requires a durable,
|
||||
inspectable log of what the application did, independent of whether an
|
||||
OpenTelemetry collector is attached (see
|
||||
[0004](0004-opentelemetry-observability.md)) — logging must work
|
||||
out-of-the-box on every environment with zero external dependencies.
|
||||
|
||||
## Decision
|
||||
- `Fuchs/Logging/FuchsLoggerProvider.cs` implements a custom
|
||||
`ILoggerProvider`/`ILogger` registered via `builder.Logging.AddFuchsLogging()`
|
||||
in `Program.cs`, with `SetMinimumLevel(LogLevel.Debug)`.
|
||||
- Every log line always goes to `Debug.WriteLine` **and** to a rolling text
|
||||
file under `<content root>/logs/` — `AppLog.txt` for
|
||||
`Debug`/`Information`/`Warning`, `ErrorLog.txt` for `Error`/`Critical` —
|
||||
so a failure investigation never depends on a debugger being attached.
|
||||
- Log lines are structured with timestamp, level tag, category, message, and
|
||||
(when present) the exception message + stack trace on continuation lines.
|
||||
- Database logging (`fuchs__admin_logdebug`) is **prepared but disabled** by
|
||||
default (`FuchsLoggerProvider.DatabaseLoggingEnabled = false`) — flip it
|
||||
on only where DB-durable diagnostics are specifically needed, since it
|
||||
adds a DB round-trip per log call.
|
||||
- All logger calls elsewhere in the codebase use `ILogger<T>` injected via
|
||||
DI with **structured** placeholders (`_logger.LogInformation("Sent {InvoiceNumber} to {Email}", ...)`),
|
||||
never interpolated strings — this is enforced project-wide (see Coding
|
||||
Standards / Observability in `CLAUDE.md`).
|
||||
- File writes are best-effort: `AppendToFile` swallows its own exceptions —
|
||||
a logging failure must never crash or interrupt the operation being
|
||||
logged.
|
||||
|
||||
## Consequences
|
||||
- New code must inject `ILogger<T>` and log entry/result/timing/errors for
|
||||
meaningful operations (see [0004](0004-opentelemetry-observability.md) for
|
||||
the matching tracing/metrics requirement) rather than adding ad-hoc
|
||||
`Console.WriteLine`/`Debug.Print` calls.
|
||||
- Because logs always write to `logs/AppLog.txt` and `ErrorLog.txt`
|
||||
regardless of telemetry configuration, these files are the first place to
|
||||
check when OTLP export isn't configured for an environment.
|
||||
- Enabling `DatabaseLoggingEnabled` is a deliberate, explicit choice per
|
||||
environment, not a default — it has a per-call DB cost.
|
||||
|
||||
## Alternatives considered
|
||||
- **Third-party logging framework (Serilog/NLog)**: rejected for now to
|
||||
avoid an extra dependency for a need the in-box `ILogger` abstraction plus
|
||||
a small custom provider already satisfies; revisit if requirements (e.g.
|
||||
structured JSON sinks, log shipping) outgrow this.
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
status: Accepted
|
||||
date: 2026-07-03
|
||||
applyTo:
|
||||
- "Fuchs/Observability/**"
|
||||
- "Fuchs/Program.cs"
|
||||
- "Fuchs/Services/**"
|
||||
supersededBy: ""
|
||||
---
|
||||
|
||||
# 0004 — OpenTelemetry is wired in extensively, without compromising performance
|
||||
|
||||
## Context
|
||||
Beyond text logs (see [0003](0003-structured-diagnostic-logging.md)), the
|
||||
solution needs distributed tracing and metrics to understand performance and
|
||||
behavior in production (PDF render durations, email send outcomes, MFR call
|
||||
volume, banking import throughput) without depending on a debugger or manual
|
||||
log-grepping — while never letting the absence of a collector break or slow
|
||||
down the app.
|
||||
|
||||
## Decision
|
||||
- All instrumentation is centralized in `Fuchs/Observability/FuchsTelemetry.cs`:
|
||||
one `ActivitySource` (`Fuchs.Intranet`) for tracing and one `Meter` for
|
||||
metrics, exposing named `Counter<long>`/`Histogram<double>` instruments
|
||||
(invoices/reminders/reports rendered, emails/SMS sent/failed, MT940 rows
|
||||
parsed, banking entries skipped/truncated, MFR calls, blob upload
|
||||
success/failure, PDF/report/email durations) plus a `StartActivity` helper.
|
||||
- Wired in `Program.cs` behind `Fuchs:Telemetry:Enabled` (default `true`):
|
||||
`AddOpenTelemetry()` with `AddAspNetCoreInstrumentation`,
|
||||
`AddHttpClientInstrumentation`, `AddSqlClientInstrumentation` for tracing,
|
||||
and `AddAspNetCoreInstrumentation`, `AddHttpClientInstrumentation`,
|
||||
`AddRuntimeInstrumentation` for metrics.
|
||||
- **Collection is always on; export is opt-in.** The OTLP exporter is only
|
||||
added when `Fuchs:Telemetry:OtlpEndpoint` is configured — with no
|
||||
collector present, spans/metrics are simply collected in-process and
|
||||
discarded, so a missing collector can never cause startup failures,
|
||||
exceptions, or blocking calls. Setting `Fuchs:Telemetry:Enabled=false`
|
||||
disables instrumentation entirely.
|
||||
- Per the project-wide Observability standard: every meaningful operation
|
||||
starts an activity via `FuchsTelemetry.StartActivity(...)`, records the
|
||||
matching counter/histogram, and logs entry/result/timing/errors via
|
||||
injected `ILogger<T>` with structured placeholders — this is enforced for
|
||||
new service/handler code, not just the initial wiring.
|
||||
|
||||
## Consequences
|
||||
- New business operations worth observing must add a named instrument to
|
||||
`FuchsTelemetry.cs` rather than creating ad-hoc `ActivitySource`/`Meter`
|
||||
instances elsewhere — one source, one meter, keeps exporters and
|
||||
dashboards simple.
|
||||
- Because export is opt-in, local/dev environments get full in-process
|
||||
instrumentation with zero setup; wiring an OTLP collector is purely an
|
||||
ops-side configuration change (`Fuchs:Telemetry:OtlpEndpoint`), not a
|
||||
code change.
|
||||
- Instrumentation must stay cheap on the hot path — use the existing
|
||||
counters/histograms rather than allocating new tags/dictionaries per call
|
||||
where avoidable, and never make a business operation depend on the
|
||||
exporter succeeding.
|
||||
|
||||
## Alternatives considered
|
||||
- **Always-on OTLP exporter requiring a collector**: rejected — would make
|
||||
local dev and any environment without a collector fail hard or add
|
||||
latency/timeouts trying to reach one.
|
||||
- **Per-service ActivitySource/Meter instances**: rejected in favor of one
|
||||
centralized `FuchsTelemetry` — avoids scattered instrument names and
|
||||
duplicate registration boilerplate in `Program.cs`.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Decisions
|
||||
|
||||
This folder holds **Architecture Decision Records (ADRs)** — short, immutable
|
||||
records of a specific technical choice, why it was made, and what it implies
|
||||
going forward.
|
||||
|
||||
## What belongs here
|
||||
|
||||
A decision, not a how-to. If it answers "why do we do X this way, and what
|
||||
else did we consider," it's a decision. If it explains "how subsystem X
|
||||
currently works," that belongs in [`../Concepts`](../Concepts/README.md)
|
||||
instead (and a decision often triggers a concept doc to be created/updated).
|
||||
|
||||
## File naming
|
||||
|
||||
`NNNN-kebab-case-title.md`, four-digit zero-padded, sequential across the
|
||||
whole folder (`0001-...`, `0002-...`). Never reuse or renumber.
|
||||
|
||||
## Required YAML frontmatter
|
||||
|
||||
Every decision file starts with:
|
||||
|
||||
```yaml
|
||||
---
|
||||
status: Accepted # Proposed | Accepted | Superseded
|
||||
date: 2026-07-03 # date the decision was accepted
|
||||
applyTo: # glob(s) — files/areas this decision governs
|
||||
- "Fuchs/Notifications/**"
|
||||
supersededBy: "" # filename of the decision that replaced this one, if any
|
||||
---
|
||||
```
|
||||
|
||||
**Agents (Claude, Copilot, Codex) must scan the YAML frontmatter of every file
|
||||
in this folder first** (cheap — no need to read the body) and only read the
|
||||
full body of decisions whose `applyTo` glob matches the files they're about
|
||||
to touch, or whose subject is otherwise clearly relevant to the task. This
|
||||
keeps decision-following cheap even as the folder grows.
|
||||
|
||||
## Body template
|
||||
|
||||
```markdown
|
||||
# NNNN — Title
|
||||
|
||||
## Context
|
||||
What problem/situation forced a choice.
|
||||
|
||||
## Decision
|
||||
What was decided, stated plainly.
|
||||
|
||||
## Consequences
|
||||
What this implies for future code — constraints, follow-ups, trade-offs
|
||||
accepted knowingly.
|
||||
|
||||
## Alternatives considered
|
||||
Options that were rejected and why (optional but preferred).
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Decisions are immutable once `Accepted`.** Do not edit the Decision/
|
||||
Consequences of an existing file to reverse it. Instead, write a new
|
||||
decision, set its `applyTo`/subject accordingly, and set the old file's
|
||||
`status: Superseded` + `supersededBy: NNNN-new-file.md`.
|
||||
- **Follow existing decisions.** Before implementing anything in an area
|
||||
covered by an `Accepted` decision, read it and conform to it. If you
|
||||
believe a decision is wrong, raise it with the user rather than silently
|
||||
deviating.
|
||||
- **Capture new decisions as they happen.** Whenever the user (or the code
|
||||
you're writing) settles a non-obvious architectural or cross-cutting
|
||||
choice — not a routine implementation detail — add a decision here in the
|
||||
same change, and create/update the matching concept doc in `../Concepts`.
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
The items, if completed, should be ticked / checked as done.
|
||||
|
||||
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the Decisions ind \Docs\Decisions must be followed
|
||||
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that whenever relevant new decisions should be captured, concept files should be created / updated
|
||||
[x] Add note to `claude.md`, `copilot-instructions.md`, and `Codex.md` that the readme.md files in \Docs\Concept and \Docs\Decisions explain how to create, update, interpret the documents. Create those readme.md files. make sure that any concepts or decisions have a yaml header that contains applyTo key. The agents should scan those yaml headers first (saving tokens) and decide based on that if included/considered
|
||||
[x] Add a first decision, that domain events (success and fails) should be identified and equiped with triggers that trigger notification to the EventService and pass on a context that allows a user understandable message like "Rechnung R2026-0001 wurde and Kunden unter test@test.de per Email versandt".
|
||||
[x] Add a decision that reflects the current concept and implementation of Notification from Service in Backend over SignalR push to any logged in session to display in GUI. (failues with highlighting)
|
||||
[x] Add a decision that the solution must be equiped with logging so that the diagnostics is possible without requiring a debugger or OTel collector.
|
||||
[x] Add OpenTelemetry to the solution. Wire it in extensively without compromising performance.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
public enum DomainEventType
|
||||
{
|
||||
InvoiceDraftCreated,
|
||||
InvoiceDraftUpdated,
|
||||
InvoiceFileCreated,
|
||||
InvoiceSentToCustomer,
|
||||
InvoiceResentToCustomer,
|
||||
InvoiceMarkedSent,
|
||||
InvoiceCreationFailed,
|
||||
InvoiceFileCreationFailed,
|
||||
InvoiceSendFailed,
|
||||
ReminderDraftCreated,
|
||||
ReminderFileCreated,
|
||||
ReminderSentToCustomer,
|
||||
ReminderResentToCustomer,
|
||||
ReminderMarkedSent,
|
||||
ReminderCreationFailed,
|
||||
ReminderFileCreationFailed,
|
||||
ReminderSendFailed,
|
||||
BankingTransactionsImported,
|
||||
BankingImportFailed,
|
||||
UserIssue
|
||||
}
|
||||
|
||||
public sealed record DomainEvent(
|
||||
DomainEventType Type,
|
||||
string UserAccountId,
|
||||
string Title,
|
||||
IReadOnlyDictionary<string, object?> Context)
|
||||
{
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
using Fuchs.intranet;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static OCORE.OCORE_dictionaries;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
public sealed class EventService : IEventService
|
||||
{
|
||||
private readonly IHubContext<NotificationHub> _hub;
|
||||
private readonly ILogger<EventService> _logger;
|
||||
|
||||
public EventService(IHubContext<NotificationHub> hub, ILogger<EventService> logger)
|
||||
{
|
||||
_hub = hub;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task PublishAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
GuiNotification notification = BuildNotification(domainEvent);
|
||||
await _hub.Clients
|
||||
.All
|
||||
.SendAsync("notification", notification, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Notification publish failed for {EventType}", domainEvent.Type);
|
||||
}
|
||||
}
|
||||
|
||||
public Task InvoiceDraftRegisteredAsync(FdsInvoiceData invoice, bool changed, string userAccountId)
|
||||
{
|
||||
var type = changed ? DomainEventType.InvoiceDraftUpdated : DomainEventType.InvoiceDraftCreated;
|
||||
return PublishAsync(new DomainEvent(type, userAccountId, "Rechnungsentwurf", InvoiceContext(invoice)));
|
||||
}
|
||||
|
||||
public Task InvoiceFileCreatedAsync(FdsInvoiceData invoice, string fileName, string userAccountId)
|
||||
{
|
||||
var ctx = InvoiceContext(invoice);
|
||||
ctx["fileName"] = fileName;
|
||||
return PublishAsync(new DomainEvent(DomainEventType.InvoiceFileCreated, userAccountId, "Rechnungsdatei", ctx));
|
||||
}
|
||||
|
||||
public Task InvoiceSentToCustomerAsync(FdsInvoiceData invoice, string email, string userAccountId, bool resent = false)
|
||||
{
|
||||
var ctx = InvoiceContext(invoice);
|
||||
ctx["email"] = email;
|
||||
return PublishAsync(new DomainEvent(
|
||||
resent ? DomainEventType.InvoiceResentToCustomer : DomainEventType.InvoiceSentToCustomer,
|
||||
userAccountId,
|
||||
"Rechnung versandt",
|
||||
ctx));
|
||||
}
|
||||
|
||||
public Task InvoiceMarkedSentAsync(string invoiceId, string invoiceNumber, string userAccountId)
|
||||
{
|
||||
Dictionary<string, object?> ctx = new()
|
||||
{
|
||||
["id"] = invoiceId,
|
||||
["invoiceNumber"] = string.IsNullOrWhiteSpace(invoiceNumber) ? invoiceId : invoiceNumber
|
||||
};
|
||||
return PublishAsync(new DomainEvent(DomainEventType.InvoiceMarkedSent, userAccountId, "Rechnung markiert", ctx));
|
||||
}
|
||||
|
||||
public Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "")
|
||||
=> PublishAsync(new DomainEvent(
|
||||
DomainEventType.InvoiceCreationFailed,
|
||||
userAccountId,
|
||||
"Rechnung",
|
||||
new Dictionary<string, object?> { ["id"] = invoiceId, ["message"] = message }));
|
||||
|
||||
public Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId)
|
||||
=> PublishAsync(new DomainEvent(DomainEventType.ReminderDraftCreated, userAccountId, "Mahnentwurf", ReminderContext(reminder)));
|
||||
|
||||
public Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId)
|
||||
{
|
||||
var ctx = ReminderContext(reminder);
|
||||
ctx["fileName"] = fileName;
|
||||
return PublishAsync(new DomainEvent(DomainEventType.ReminderFileCreated, userAccountId, "Mahndatei", ctx));
|
||||
}
|
||||
|
||||
public Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false)
|
||||
{
|
||||
var ctx = ReminderContext(reminder);
|
||||
ctx["email"] = email;
|
||||
return PublishAsync(new DomainEvent(
|
||||
resent ? DomainEventType.ReminderResentToCustomer : DomainEventType.ReminderSentToCustomer,
|
||||
userAccountId,
|
||||
"Mahnung versandt",
|
||||
ctx));
|
||||
}
|
||||
|
||||
public Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId)
|
||||
{
|
||||
Dictionary<string, object?> ctx = new()
|
||||
{
|
||||
["id"] = reminderId,
|
||||
["title"] = string.IsNullOrWhiteSpace(reminderTitle) ? reminderId : reminderTitle
|
||||
};
|
||||
return PublishAsync(new DomainEvent(DomainEventType.ReminderMarkedSent, userAccountId, "Mahnung markiert", ctx));
|
||||
}
|
||||
|
||||
public Task ReminderIssueAsync(string message, string userAccountId, string reminderId = "")
|
||||
=> PublishAsync(new DomainEvent(
|
||||
DomainEventType.ReminderCreationFailed,
|
||||
userAccountId,
|
||||
"Mahnung",
|
||||
new Dictionary<string, object?> { ["id"] = reminderId, ["message"] = message }));
|
||||
|
||||
public Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId)
|
||||
=> PublishAsync(new DomainEvent(
|
||||
DomainEventType.BankingTransactionsImported,
|
||||
userAccountId,
|
||||
"Banking",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["from"] = from,
|
||||
["to"] = to,
|
||||
["rows"] = rows,
|
||||
["fileName"] = fileName
|
||||
}));
|
||||
|
||||
public Task BankingImportIssueAsync(string message, string fileName, string userAccountId)
|
||||
=> PublishAsync(new DomainEvent(
|
||||
DomainEventType.BankingImportFailed,
|
||||
userAccountId,
|
||||
"Banking",
|
||||
new Dictionary<string, object?> { ["fileName"] = fileName, ["message"] = message }));
|
||||
|
||||
public Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null)
|
||||
{
|
||||
Dictionary<string, object?> ctx = context == null
|
||||
? new Dictionary<string, object?>()
|
||||
: new Dictionary<string, object?>(context);
|
||||
ctx["message"] = message;
|
||||
return PublishAsync(new DomainEvent(DomainEventType.UserIssue, userAccountId, title, ctx));
|
||||
}
|
||||
|
||||
private static GuiNotification BuildNotification(DomainEvent domainEvent)
|
||||
{
|
||||
string message = domainEvent.Type switch
|
||||
{
|
||||
DomainEventType.InvoiceDraftCreated =>
|
||||
$"Rechnungsentwurf {Ctx(domainEvent, "invoiceNumber")} wurde erstellt.",
|
||||
DomainEventType.InvoiceDraftUpdated =>
|
||||
$"Rechnungsentwurf {Ctx(domainEvent, "invoiceNumber")} wurde aktualisiert.",
|
||||
DomainEventType.InvoiceFileCreated =>
|
||||
$"Rechnungsdatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
||||
DomainEventType.InvoiceSentToCustomer =>
|
||||
$"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde an den Kunden mit der E-Mail {Ctx(domainEvent, "email")} versandt.",
|
||||
DomainEventType.InvoiceResentToCustomer =>
|
||||
$"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde erneut an {Ctx(domainEvent, "email")} versandt.",
|
||||
DomainEventType.InvoiceMarkedSent =>
|
||||
$"Rechnung {Ctx(domainEvent, "invoiceNumber")} wurde als versandt markiert.",
|
||||
DomainEventType.InvoiceCreationFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.InvoiceFileCreationFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.InvoiceSendFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.ReminderDraftCreated =>
|
||||
$"Mahnentwurf {Ctx(domainEvent, "title")} wurde erstellt.",
|
||||
DomainEventType.ReminderFileCreated =>
|
||||
$"Mahndatei {Ctx(domainEvent, "fileName")} wurde erstellt.",
|
||||
DomainEventType.ReminderSentToCustomer =>
|
||||
$"Mahnung {Ctx(domainEvent, "title")} wurde an den Kunden mit der E-Mail {Ctx(domainEvent, "email")} versandt.",
|
||||
DomainEventType.ReminderResentToCustomer =>
|
||||
$"Mahnung {Ctx(domainEvent, "title")} wurde erneut an {Ctx(domainEvent, "email")} versandt.",
|
||||
DomainEventType.ReminderMarkedSent =>
|
||||
$"Mahnung {Ctx(domainEvent, "title")} wurde als versandt markiert.",
|
||||
DomainEventType.ReminderCreationFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.ReminderFileCreationFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.ReminderSendFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.BankingTransactionsImported =>
|
||||
BankingImportMessage(domainEvent),
|
||||
DomainEventType.BankingImportFailed =>
|
||||
Ctx(domainEvent, "message"),
|
||||
DomainEventType.UserIssue =>
|
||||
Ctx(domainEvent, "message"),
|
||||
_ => domainEvent.Title
|
||||
};
|
||||
|
||||
return new GuiNotification(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
domainEvent.Type.ToString(),
|
||||
domainEvent.Title,
|
||||
message,
|
||||
IsFailure(domainEvent.Type) ? "error" : "info",
|
||||
domainEvent.CreatedAt,
|
||||
domainEvent.Context);
|
||||
}
|
||||
|
||||
private static bool IsFailure(DomainEventType type) =>
|
||||
type is DomainEventType.InvoiceCreationFailed
|
||||
or DomainEventType.InvoiceFileCreationFailed
|
||||
or DomainEventType.InvoiceSendFailed
|
||||
or DomainEventType.ReminderCreationFailed
|
||||
or DomainEventType.ReminderFileCreationFailed
|
||||
or DomainEventType.ReminderSendFailed
|
||||
or DomainEventType.BankingImportFailed
|
||||
or DomainEventType.UserIssue;
|
||||
|
||||
private static string BankingImportMessage(DomainEvent domainEvent)
|
||||
{
|
||||
int rows = int.TryParse(Ctx(domainEvent, "rows"), out int r) ? r : 0;
|
||||
string movement = rows == 1 ? "Kontobewegung" : "Kontobewegungen";
|
||||
string period = BankingPeriod(domainEvent);
|
||||
return string.IsNullOrEmpty(period)
|
||||
? $"{rows} {movement} wurden importiert."
|
||||
: $"{movement} für {period} wurden importiert.";
|
||||
}
|
||||
|
||||
private static string BankingPeriod(DomainEvent domainEvent)
|
||||
{
|
||||
DateTime? from = DateCtx(domainEvent, "from");
|
||||
DateTime? to = DateCtx(domainEvent, "to");
|
||||
if (from == null && to == null) return "";
|
||||
if (from != null && to != null)
|
||||
{
|
||||
string fromFmt = from.Value.Year == to.Value.Year
|
||||
? from.Value.ToString("d.M.")
|
||||
: from.Value.ToString("d.M.yyyy");
|
||||
string toFmt = from.Value.Year == to.Value.Year
|
||||
? to.Value.ToString("dd.MM.")
|
||||
: to.Value.ToString("dd.MM.yyyy");
|
||||
return $"{fromFmt} - {toFmt}";
|
||||
}
|
||||
return (from ?? to)!.Value.ToString("dd.MM.yyyy");
|
||||
}
|
||||
|
||||
private static DateTime? DateCtx(DomainEvent domainEvent, string key)
|
||||
{
|
||||
if (!domainEvent.Context.TryGetValue(key, out var value) || value == null) return null;
|
||||
if (value is DateTime dt) return dt;
|
||||
if (value is DateTimeOffset dto) return dto.DateTime;
|
||||
return DateTime.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> InvoiceContext(FdsInvoiceData invoice)
|
||||
{
|
||||
string invoiceNumber = invoice.InvoiceId;
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = invoice.Id,
|
||||
["invoiceNumber"] = string.IsNullOrWhiteSpace(invoiceNumber) ? invoice.Id : invoiceNumber,
|
||||
["documentName"] = invoice.InvoiceRegistration?.getString("DocumentName") ?? "",
|
||||
["email"] = invoice.InvoiceRegistration?.getString("SendToEmail") ?? "",
|
||||
["title"] = invoice.InvoiceTitle
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ReminderContext(FdsReminderData reminder)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = reminder.Id,
|
||||
["invoiceNumber"] = reminder.InvoiceId,
|
||||
["title"] = string.IsNullOrWhiteSpace(reminder.ReminderTitle) ? reminder.Id : reminder.ReminderTitle,
|
||||
["documentName"] = reminder.ReminderRegistration?.getString("DocumentName") ?? "",
|
||||
["email"] = reminder.InvoiceEmail
|
||||
};
|
||||
}
|
||||
|
||||
private static string Ctx(DomainEvent domainEvent, string key)
|
||||
{
|
||||
if (!domainEvent.Context.TryGetValue(key, out var value)) return "";
|
||||
return value?.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
public sealed record GuiNotification(
|
||||
string Id,
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
DateTimeOffset CreatedAt,
|
||||
IReadOnlyDictionary<string, object?> Context);
|
||||
@@ -0,0 +1,25 @@
|
||||
using Fuchs.intranet;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
public interface IEventService
|
||||
{
|
||||
Task PublishAsync(DomainEvent domainEvent, CancellationToken cancellationToken = default);
|
||||
|
||||
Task InvoiceDraftRegisteredAsync(FdsInvoiceData invoice, bool changed, string userAccountId);
|
||||
Task InvoiceFileCreatedAsync(FdsInvoiceData invoice, string fileName, string userAccountId);
|
||||
Task InvoiceSentToCustomerAsync(FdsInvoiceData invoice, string email, string userAccountId, bool resent = false);
|
||||
Task InvoiceMarkedSentAsync(string invoiceId, string invoiceNumber, string userAccountId);
|
||||
Task InvoiceIssueAsync(string message, string userAccountId, string invoiceId = "");
|
||||
|
||||
Task ReminderDraftCreatedAsync(FdsReminderData reminder, string userAccountId);
|
||||
Task ReminderFileCreatedAsync(FdsReminderData reminder, string fileName, string userAccountId);
|
||||
Task ReminderSentToCustomerAsync(FdsReminderData reminder, string email, string userAccountId, bool resent = false);
|
||||
Task ReminderMarkedSentAsync(string reminderId, string reminderTitle, string userAccountId);
|
||||
Task ReminderIssueAsync(string message, string userAccountId, string reminderId = "");
|
||||
|
||||
Task BankingTransactionsImportedAsync(DateTime? from, DateTime? to, int rows, string fileName, string userAccountId);
|
||||
Task BankingImportIssueAsync(string message, string fileName, string userAccountId);
|
||||
|
||||
Task UserIssueAsync(string title, string message, string userAccountId, IReadOnlyDictionary<string, object?>? context = null);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Fuchs.Notifications;
|
||||
|
||||
[Authorize]
|
||||
public sealed class NotificationHub : Hub
|
||||
{
|
||||
}
|
||||
@@ -39,6 +39,12 @@ public static class FuchsTelemetry
|
||||
Meter.CreateCounter<long>("fuchs.sms.sent", "{sms}", "Number of SMS messages sent.");
|
||||
public static readonly Counter<long> Mt940RowsParsed =
|
||||
Meter.CreateCounter<long>("fuchs.banking.mt940.rows", "{row}", "Number of MT940 transaction lines parsed.");
|
||||
public static readonly Counter<long> BankingEntriesSkipped =
|
||||
Meter.CreateCounter<long>("fuchs.banking.entries.skipped", "{entry}",
|
||||
"Number of bank statement entries/statements dropped during parsing, tagged by reason.");
|
||||
public static readonly Counter<long> BankingFieldsTruncated =
|
||||
Meter.CreateCounter<long>("fuchs.banking.fields.truncated", "{field}",
|
||||
"Number of parsed fields truncated to fit the destination column width.");
|
||||
public static readonly Counter<long> MfrCalls =
|
||||
Meter.CreateCounter<long>("fuchs.mfr.calls", "{call}", "Number of MFR ERP client calls initiated.");
|
||||
public static readonly Counter<long> BlobUploadsSucceeded =
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Logging;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using OCORE_web.Secrets;
|
||||
using Fuchs.Services;
|
||||
@@ -52,6 +53,7 @@ public class Program
|
||||
|
||||
// MVC with Razor view support
|
||||
builder.Services.AddControllersWithViews();
|
||||
builder.Services.AddSignalR();
|
||||
|
||||
// Fuchs intranet singleton
|
||||
builder.Services.AddSingleton(_ => FuchsOcmsIntranet.Instance);
|
||||
@@ -96,6 +98,7 @@ public class Program
|
||||
builder.Services.AddScoped<IReportService, FuchsReportService>();
|
||||
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
|
||||
builder.Services.AddScoped<IReminderService, ReminderService>();
|
||||
builder.Services.AddScoped<IEventService, EventService>();
|
||||
|
||||
// Secondary archive: invoice/reminder PDFs additionally stored in Azure Blob Storage.
|
||||
// Disabled by default (Fuchs:AzureStorage:Enabled) — see AzureBlobStorageService.
|
||||
@@ -165,6 +168,7 @@ public class Program
|
||||
app.UseRouting();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapHub<NotificationHub>("/notifications");
|
||||
|
||||
// Intranet routes (root-level — this IS the website)
|
||||
app.MapControllerRoute(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using CAMTParser;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -35,6 +36,7 @@ public class BankingService : IBankingService
|
||||
using var act = FuchsTelemetry.StartActivity("banking.parse");
|
||||
var sw = Stopwatch.StartNew();
|
||||
var tbl = schemaDatatable?.Clone() ?? BuildDefaultSchema();
|
||||
var diag = new ParseDiagnostics();
|
||||
|
||||
// Buffer once so we can sniff the format and (re)parse from the bytes.
|
||||
byte[] bytes;
|
||||
@@ -48,46 +50,125 @@ public class BankingService : IBankingService
|
||||
if (CamtParser.LooksLikeZip(bytes))
|
||||
{
|
||||
format = "camt.zip";
|
||||
try { MapCamtEntries(tbl, new CamtParser().ParseZip(bytes)); }
|
||||
try
|
||||
{
|
||||
var statements = new CamtParser().ParseZip(bytes, out var skippedEntries);
|
||||
if (skippedEntries.Count > 0)
|
||||
{
|
||||
diag.ZipEntriesSkipped = skippedEntries.Count;
|
||||
_logger.LogWarning(
|
||||
"CAMT ZIP: {Count} entry(ies) skipped: {Entries}",
|
||||
skippedEntries.Count, string.Join("; ", skippedEntries));
|
||||
}
|
||||
MapCamtEntries(tbl, statements, diag);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogError(ex, "CAMT ZIP statement parse failed."); }
|
||||
}
|
||||
else if (CamtParser.LooksLikeXml(bytes))
|
||||
{
|
||||
format = "camt";
|
||||
try { MapCamtEntries(tbl, new CamtParser().Parse(bytes)); }
|
||||
try { MapCamtEntries(tbl, new CamtParser().Parse(bytes), diag); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "CAMT statement parse failed."); }
|
||||
}
|
||||
else
|
||||
{
|
||||
format = "mt940";
|
||||
using var msMt = new MemoryStream(bytes);
|
||||
FillFromMt940(tbl, msMt);
|
||||
FillFromMt940(tbl, msMt, diag);
|
||||
}
|
||||
|
||||
tbl.AcceptChanges();
|
||||
sw.Stop();
|
||||
|
||||
FuchsTelemetry.Mt940RowsParsed.Add(tbl.Rows.Count, new KeyValuePair<string, object?>("format", format));
|
||||
if (diag.StatementsSkippedNoAccount > 0)
|
||||
FuchsTelemetry.BankingEntriesSkipped.Add(diag.StatementsSkippedNoAccount,
|
||||
new KeyValuePair<string, object?>("reason", "noAccount"));
|
||||
if (diag.EntriesSkippedError > 0)
|
||||
FuchsTelemetry.BankingEntriesSkipped.Add(diag.EntriesSkippedError,
|
||||
new KeyValuePair<string, object?>("reason", "error"));
|
||||
if (diag.ZipEntriesSkipped > 0)
|
||||
FuchsTelemetry.BankingEntriesSkipped.Add(diag.ZipEntriesSkipped,
|
||||
new KeyValuePair<string, object?>("reason", "zipEntry"));
|
||||
if (diag.FieldsTruncated > 0)
|
||||
FuchsTelemetry.BankingFieldsTruncated.Add(diag.FieldsTruncated);
|
||||
|
||||
act?.SetTag("fuchs.banking.format", format);
|
||||
act?.SetTag("fuchs.banking.rows", tbl.Rows.Count);
|
||||
_logger.LogInformation("Bank statement parsed: format={Format} rows={Rows} in {Ms} ms",
|
||||
format, tbl.Rows.Count, sw.ElapsedMilliseconds);
|
||||
act?.SetTag("fuchs.banking.statements_skipped_no_account", diag.StatementsSkippedNoAccount);
|
||||
act?.SetTag("fuchs.banking.entries_skipped_error", diag.EntriesSkippedError);
|
||||
act?.SetTag("fuchs.banking.fields_truncated", diag.FieldsTruncated);
|
||||
|
||||
// A statement/upload that yields zero rows almost always means the import silently
|
||||
// failed upstream (wrong account element for this bank's schema variant, empty file,
|
||||
// unsupported CAMT flavor) rather than that the statement legitimately had no bookings.
|
||||
// Surface that as a warning so it doesn't require deliberately grepping info-level logs.
|
||||
var logLevel = tbl.Rows.Count == 0 ? LogLevel.Warning : LogLevel.Information;
|
||||
_logger.Log(logLevel,
|
||||
"Bank statement parsed: format={Format} rows={Rows} statementsSkippedNoAccount={StatementsSkipped} " +
|
||||
"entriesSkippedError={EntriesSkipped} zipEntriesSkipped={ZipSkipped} fieldsTruncated={Truncated} in {Ms} ms",
|
||||
format, tbl.Rows.Count, diag.StatementsSkippedNoAccount, diag.EntriesSkippedError,
|
||||
diag.ZipEntriesSkipped, diag.FieldsTruncated, sw.ElapsedMilliseconds);
|
||||
|
||||
if (diag.TruncatedByColumn.Count > 0)
|
||||
_logger.LogWarning("Bank statement fields truncated to column width: {Columns}",
|
||||
string.Join(", ", diag.TruncatedByColumn.Select(kv => $"{kv.Key}×{kv.Value}")));
|
||||
|
||||
return tbl;
|
||||
}
|
||||
|
||||
// ── MT940 ─────────────────────────────────────────────────────────────────
|
||||
private void FillFromMt940(DataTable tbl, Stream stream)
|
||||
/// <summary>Per-parse counters used to summarize what got dropped or altered, so a single log line can explain a zero- or low-row result.</summary>
|
||||
private sealed class ParseDiagnostics
|
||||
{
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
public int StatementsSkippedNoAccount;
|
||||
public int EntriesSkippedError;
|
||||
public int ZipEntriesSkipped;
|
||||
public int FieldsTruncated;
|
||||
public readonly Dictionary<string, int> TruncatedByColumn = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assigns a value to a row cell, but only if the column exists and the value
|
||||
/// is non-null. String values are truncated to the column's <see cref="DataColumn.MaxLength"/>
|
||||
/// so that an over-long field (e.g. a remittance line, a long counterparty name, or a
|
||||
/// foreign IBAN) can never overflow the destination column. Without this guard, a single
|
||||
/// over-long value throws on assignment and — because the per-entry mapping is wrapped in a
|
||||
/// catch — silently drops the whole transaction, which can empty an entire import.
|
||||
/// Truncations are counted in <paramref name="diag"/> rather than logged per-cell, to avoid
|
||||
/// flooding the log on a file with many long fields.
|
||||
/// </summary>
|
||||
private static void SetCell(DataTable tbl, DataRow nr, string key, object? value, ParseDiagnostics diag)
|
||||
{
|
||||
if (value == null || !tbl.Columns.Contains(key)) return;
|
||||
var col = tbl.Columns[key]!;
|
||||
if (col.DataType == typeof(string) && col.MaxLength > 0 &&
|
||||
value is string s && s.Length > col.MaxLength)
|
||||
{
|
||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||
value = s[..col.MaxLength];
|
||||
diag.FieldsTruncated++;
|
||||
diag.TruncatedByColumn[key] = diag.TruncatedByColumn.GetValueOrDefault(key) + 1;
|
||||
}
|
||||
nr[key] = value;
|
||||
}
|
||||
|
||||
// ── MT940 ─────────────────────────────────────────────────────────────────
|
||||
private void FillFromMt940(DataTable tbl, Stream stream, ParseDiagnostics diag)
|
||||
{
|
||||
void SetNfo(DataRow nr, string key, object? value) => SetCell(tbl, nr, key, value, diag);
|
||||
|
||||
using var ps = new Parser(stream: stream);
|
||||
try
|
||||
{
|
||||
foreach (var statement in ps.Parse())
|
||||
{
|
||||
if (string.IsNullOrEmpty(statement.AccountIdentification)) continue;
|
||||
if (string.IsNullOrEmpty(statement.AccountIdentification))
|
||||
{
|
||||
diag.StatementsSkippedNoAccount++;
|
||||
_logger.LogWarning(
|
||||
"MT940 statement skipped: no AccountIdentification ({LineCount} line(s) dropped).",
|
||||
statement.Lines.Count);
|
||||
continue;
|
||||
}
|
||||
foreach (var line in statement.Lines)
|
||||
{
|
||||
try
|
||||
@@ -128,7 +209,13 @@ public class BankingService : IBankingService
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "MT940 line parse error — account={Account}", statement.AccountIdentification); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
diag.EntriesSkippedError++;
|
||||
_logger.LogWarning(ex,
|
||||
"MT940 line parse error — account={Account} entryDate={EntryDate} amount={Amount}: dropped.",
|
||||
statement.AccountIdentification, line.EntryDate, line.Amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,16 +223,20 @@ public class BankingService : IBankingService
|
||||
}
|
||||
|
||||
// ── CAMT (ISO 20022) ───────────────────────────────────────────────────────
|
||||
private void MapCamtEntries(DataTable tbl, List<CamtStatement> statements)
|
||||
private void MapCamtEntries(DataTable tbl, List<CamtStatement> statements, ParseDiagnostics diag)
|
||||
{
|
||||
void SetNfo(DataRow nr, string key, object? value)
|
||||
{
|
||||
if (tbl.Columns.Contains(key) && value != null) nr[key] = value;
|
||||
}
|
||||
void SetNfo(DataRow nr, string key, object? value) => SetCell(tbl, nr, key, value, diag);
|
||||
|
||||
foreach (var stmt in statements)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stmt.AccountIdentification)) continue;
|
||||
if (string.IsNullOrEmpty(stmt.AccountIdentification))
|
||||
{
|
||||
diag.StatementsSkippedNoAccount++;
|
||||
_logger.LogWarning(
|
||||
"CAMT statement skipped: no AccountIdentification ({EntryCount} entrie(s) dropped, docType={DocType}).",
|
||||
stmt.Entries.Count, stmt.DocumentType);
|
||||
continue;
|
||||
}
|
||||
foreach (var e in stmt.Entries)
|
||||
{
|
||||
try
|
||||
@@ -155,7 +246,10 @@ public class BankingService : IBankingService
|
||||
if (e.Amount.HasValue) SetNfo(nr, "Amount", e.Amount);
|
||||
if (e.EntryDate.HasValue) SetNfo(nr, "EntryDate", e.EntryDate);
|
||||
if (e.ValueDate.HasValue) SetNfo(nr, "ValueDate", e.ValueDate);
|
||||
SetNfo(nr, "FundsCode", e.Currency);
|
||||
// FundsCode is a single-character MT940 funds code (VARCHAR(1)); CAMT has no
|
||||
// equivalent, so it is left unset. The ISO currency (e.Currency, e.g. "EUR")
|
||||
// must NOT be written here — it overflows the 1-char column and, before the
|
||||
// width guard in SetCell, silently dropped every CAMT transaction.
|
||||
SetNfo(nr, "DebitCreditMark", e.MarkAbbreviation);
|
||||
SetNfo(nr, "BankReference", e.BankReference);
|
||||
SetNfo(nr, "EndToEndReference", e.EndToEndReference);
|
||||
@@ -175,7 +269,13 @@ public class BankingService : IBankingService
|
||||
|
||||
tbl.Rows.Add(nr);
|
||||
}
|
||||
catch (Exception ex) { _logger.LogWarning(ex, "CAMT entry parse error — account={Account}", stmt.AccountIdentification); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
diag.EntriesSkippedError++;
|
||||
_logger.LogWarning(ex,
|
||||
"CAMT entry parse error — account={Account} entryDate={EntryDate} amount={Amount}: dropped.",
|
||||
stmt.AccountIdentification, e.EntryDate, e.Amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -23,14 +24,16 @@ public class InvoiceService : IInvoiceService
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly IEventService _events;
|
||||
private readonly ILogger<InvoiceService> _logger;
|
||||
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
ILogger<InvoiceService> logger)
|
||||
IEventService events, ILogger<InvoiceService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
_blobStorage = blobStorage;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -150,6 +153,7 @@ public class InvoiceService : IInvoiceService
|
||||
string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
|
||||
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
|
||||
await _blobStorage.UploadInvoicePdfAsync(invoice.Id, fileName, ba, invoice.InvoiceRegistration);
|
||||
await _events.InvoiceFileCreatedAsync(invoice, fileName, userAccountId);
|
||||
return ba;
|
||||
}
|
||||
|
||||
|
||||
@@ -234,16 +234,22 @@ public class ProcessWebComService : IComService
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildSignature()
|
||||
private string BuildSignature()
|
||||
{
|
||||
string sigPath = Path.Combine(AppContext.BaseDirectory,
|
||||
"email_signature", "sanitaerfuchs_email_signature.txt");
|
||||
try
|
||||
{
|
||||
string sigPath = Path.Combine(AppContext.BaseDirectory,
|
||||
"email_signature", "sanitaerfuchs_email_signature.txt");
|
||||
if (File.Exists(sigPath))
|
||||
return SignatureIntro + File.ReadAllText(sigPath);
|
||||
}
|
||||
catch { /* signature is optional */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The signature is optional (emails still send without it), but a read failure here
|
||||
// usually means a misconfigured deployment (permissions, locked file) that would
|
||||
// otherwise go unnoticed indefinitely — every email would just quietly lack a signature.
|
||||
_logger.LogWarning(ex, "Failed to read email signature file at {SignaturePath}", sigPath);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -23,14 +24,16 @@ public class ReminderService : IReminderService
|
||||
private readonly Fuchs_intranet _intranet;
|
||||
private readonly IPdfService _pdf;
|
||||
private readonly IBlobStorageService _blobStorage;
|
||||
private readonly IEventService _events;
|
||||
private readonly ILogger<ReminderService> _logger;
|
||||
|
||||
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
ILogger<ReminderService> logger)
|
||||
IEventService events, ILogger<ReminderService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
_blobStorage = blobStorage;
|
||||
_events = events;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -153,6 +156,7 @@ public class ReminderService : IReminderService
|
||||
string fileName = reminder.ReminderRegistration?.getString("DocumentName")
|
||||
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
|
||||
await _blobStorage.UploadReminderPdfAsync(reminder.Id, fileName, ba, reminder.ReminderRegistration);
|
||||
await _events.ReminderFileCreatedAsync(reminder, fileName, userAccountId);
|
||||
return ba;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.Data.SqlClient
|
||||
@using Newtonsoft.Json
|
||||
@inject IConfiguration Configuration
|
||||
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment HostEnvironment
|
||||
@{
|
||||
bool isAuth = User.Identity?.IsAuthenticated ?? false;
|
||||
|
||||
@@ -16,12 +19,26 @@
|
||||
string appName = ViewData["AppName"] as string ?? "Fuchs Intranet";
|
||||
string fullName = ViewData["FullName"] as string ?? "";
|
||||
string pageTitle = ViewData["Title"] as string ?? "Intranet";
|
||||
string? debugDbTarget = null;
|
||||
if (HostEnvironment.IsDevelopment())
|
||||
{
|
||||
var connectionString = Configuration.GetConnectionString("fuchs_fds_ConnectionString");
|
||||
if (!string.IsNullOrWhiteSpace(connectionString))
|
||||
{
|
||||
var builder = new SqlConnectionStringBuilder(connectionString);
|
||||
debugDbTarget = $"{builder.DataSource} / {builder.InitialCatalog}";
|
||||
}
|
||||
}
|
||||
}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@if (!string.IsNullOrWhiteSpace(debugDbTarget))
|
||||
{
|
||||
<meta name="fuchs-debug-database" content="@debugDbTarget" />
|
||||
}
|
||||
<title>@pageTitle</title>
|
||||
<script src="~/web/tools.js" asp-append-version="true"></script>
|
||||
|
||||
@@ -92,6 +109,7 @@
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<div id="notification_frame"></div>
|
||||
@await RenderSectionAsync("BodyFooter", required: false)
|
||||
</footer>
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"outputFileName": "wwwroot/web/fis.min.js",
|
||||
"inputFiles": [
|
||||
"js/intranet/oci_texts_basic_de.js",
|
||||
"node_modules/@microsoft/signalr/dist/browser/signalr.min.js",
|
||||
"js/intranet/oci_texts_gui_de.js",
|
||||
"js/intranet/oci_texts_val_de.js",
|
||||
"web/loadcss/loadCSS.js",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
@@ -218,14 +219,21 @@ public class FuchsUserIdentity
|
||||
// --------------------------- SQL options -------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Fuchs-specific SQL options — adds debug logging on error.
|
||||
/// Fuchs-specific SQL options — logs every SQL error both to the app's structured
|
||||
/// <see cref="ILogger"/>/OpenTelemetry pipeline and to the <c>fds__admin_logdebug</c> SQL
|
||||
/// table (via <see cref="Fuchs_intranet.debug_log"/>). Handlers that don't separately inspect
|
||||
/// the result's <c>.Exception</c> would otherwise return an empty/200 response on a failing
|
||||
/// stored procedure with zero application-log signal — the DB table alone requires someone to
|
||||
/// go looking for it.
|
||||
/// </summary>
|
||||
public class FIS_SQLOptions : sqloptions
|
||||
{
|
||||
public FIS_SQLOptions(Dictionary<string, object>? context = null)
|
||||
public FIS_SQLOptions(Dictionary<string, object>? context = null, ILogger? logger = null)
|
||||
{
|
||||
OnError = (procedure, ex, data) =>
|
||||
{
|
||||
logger?.LogError(ex, "SQL error in {Procedure}: {Message} — context={@Context}",
|
||||
procedure, ex.Message, context);
|
||||
try { FuchsOcmsIntranet.Instance.debug_log($"SQL Error in {procedure}", ex, data: context); }
|
||||
catch { }
|
||||
};
|
||||
|
||||
@@ -29,6 +29,61 @@ main nav ul > li a[role=button] {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#notification_frame {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2000;
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification_item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-left: 0.35rem solid $fuchs_blau;
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
|
||||
color: #222;
|
||||
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
|
||||
pointer-events: auto;
|
||||
|
||||
&.warn {
|
||||
border-left-color: #c78300;
|
||||
}
|
||||
|
||||
&.error {
|
||||
border-left-color: #b92525;
|
||||
}
|
||||
|
||||
.notification_title {
|
||||
font-weight: bold;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.notification_message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notification_close {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.45rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #444;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wdg_frame {
|
||||
background-color: #FFF;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
@@ -228,4 +228,45 @@ $fis.ov = function () {
|
||||
});
|
||||
}, loading: ovf
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
$fis.notifications = {
|
||||
connection: null,
|
||||
init: function () {
|
||||
if (typeof signalR === 'undefined' || this.connection !== null || !$ocms.auth.useraccount_id) {
|
||||
return;
|
||||
}
|
||||
this.ensureFrame();
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/notifications')
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
this.connection.on('notification', (notification) => {
|
||||
this.push(notification);
|
||||
});
|
||||
this.connection.start().catch(() => {
|
||||
this.connection = null;
|
||||
});
|
||||
},
|
||||
ensureFrame: function () {
|
||||
if ($('#notification_frame').length < 1) {
|
||||
$('<div/>', { id: 'notification_frame' }).appendTo($('footer:first').length ? 'footer:first' : 'body');
|
||||
}
|
||||
},
|
||||
push: function (notification) {
|
||||
this.ensureFrame();
|
||||
notification = notification || {};
|
||||
let item = $('<div/>', { class: 'notification_item' })
|
||||
.addClass((notification.severity || 'info').toLowerCase())
|
||||
.append($('<button/>', { type: 'button', class: 'notification_close', text: '×' }))
|
||||
.append($('<div/>', { class: 'notification_title', text: notification.title || 'Info' }))
|
||||
.append($('<div/>', { class: 'notification_message', text: notification.message || '' }));
|
||||
item.find('.notification_close').on('click', function () {
|
||||
item.remove();
|
||||
});
|
||||
$('#notification_frame').prepend(item);
|
||||
setTimeout(function () {
|
||||
item.fadeOut(150, function () { item.remove(); });
|
||||
}, 9000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
$(document).ready(function () {
|
||||
$fis.notifications.init();
|
||||
$fis.ov();
|
||||
});
|
||||
|
||||
Generated
+315
@@ -8,6 +8,7 @@
|
||||
"name": "fuchs",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"fg-loadcss": "3.1.0",
|
||||
"jquery": "4.0.0",
|
||||
"js-cookie": "3.0.1",
|
||||
@@ -51,6 +52,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/signalr": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
|
||||
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^2.0.2",
|
||||
"fetch-cookie": "^2.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"ws": "^7.5.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -426,6 +440,18 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
|
||||
@@ -1026,6 +1052,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
@@ -1036,6 +1071,15 @@
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-tilde": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||
@@ -1150,6 +1194,16 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fetch-cookie": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
|
||||
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"set-cookie-parser": "^2.4.8",
|
||||
"tough-cookie": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fg-loadcss": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz",
|
||||
@@ -2457,6 +2511,26 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -2677,6 +2751,33 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2789,6 +2890,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -2966,6 +3073,12 @@
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
@@ -3281,6 +3394,27 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
@@ -3367,12 +3501,31 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/upper-case": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
|
||||
"integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -3588,6 +3741,22 @@
|
||||
"source-map": "^0.5.1"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
@@ -3641,6 +3810,27 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": "^5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
|
||||
@@ -3706,6 +3896,18 @@
|
||||
"is-negated-glob": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"@microsoft/signalr": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz",
|
||||
"integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==",
|
||||
"requires": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"eventsource": "^2.0.2",
|
||||
"fetch-cookie": "^2.0.3",
|
||||
"node-fetch": "^2.6.7",
|
||||
"ws": "^7.5.10"
|
||||
}
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -3864,6 +4066,14 @@
|
||||
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
|
||||
"dev": true
|
||||
},
|
||||
"abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"requires": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"ansi-colors": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz",
|
||||
@@ -4296,6 +4506,11 @@
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true
|
||||
},
|
||||
"event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="
|
||||
},
|
||||
"events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
@@ -4305,6 +4520,11 @@
|
||||
"bare-events": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"eventsource": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
|
||||
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA=="
|
||||
},
|
||||
"expand-tilde": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz",
|
||||
@@ -4396,6 +4616,15 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"fetch-cookie": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
|
||||
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
|
||||
"requires": {
|
||||
"set-cookie-parser": "^2.4.8",
|
||||
"tough-cookie": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"fg-loadcss": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fg-loadcss/-/fg-loadcss-3.1.0.tgz",
|
||||
@@ -5356,6 +5585,14 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"requires": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -5510,6 +5747,24 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"requires": {
|
||||
"punycode": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
|
||||
},
|
||||
"querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
|
||||
},
|
||||
"queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5587,6 +5842,11 @@
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true
|
||||
},
|
||||
"requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||
},
|
||||
"resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -5697,6 +5957,11 @@
|
||||
"sver": "^1.8.3"
|
||||
}
|
||||
},
|
||||
"set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
@@ -5946,6 +6211,22 @@
|
||||
"streamx": "^2.12.5"
|
||||
}
|
||||
},
|
||||
"tough-cookie": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
|
||||
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
|
||||
"requires": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
}
|
||||
},
|
||||
"tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
@@ -6006,12 +6287,26 @@
|
||||
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
|
||||
"dev": true
|
||||
},
|
||||
"universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg=="
|
||||
},
|
||||
"upper-case": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/upper-case/-/upper-case-1.1.3.tgz",
|
||||
"integrity": "sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=",
|
||||
"dev": true
|
||||
},
|
||||
"url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"requires": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -6180,6 +6475,20 @@
|
||||
"source-map": "^0.5.1"
|
||||
}
|
||||
},
|
||||
"webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"requires": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"which": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
|
||||
@@ -6217,6 +6526,12 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"ws": {
|
||||
"version": "7.5.11",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz",
|
||||
"integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==",
|
||||
"requires": {}
|
||||
},
|
||||
"xtend": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz",
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
{
|
||||
{
|
||||
"name": "fuchs",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"fg-loadcss": "3.1.0",
|
||||
"jquery": "4.0.0",
|
||||
"js-cookie": "3.0.1",
|
||||
|
||||
@@ -2230,6 +2230,56 @@ main nav ul > li a[role=button]:hover::after, main nav ul > li a[role=button].fb
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#notification_frame {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2000;
|
||||
width: min(24rem, 100vw - 2rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification_item {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-left: 0.35rem solid rgb(27, 67, 121);
|
||||
border-radius: 0.35rem;
|
||||
box-shadow: 0 0.25rem 1rem rgba(30, 35, 45, 0.25);
|
||||
color: #222;
|
||||
padding: 0.75rem 2.2rem 0.75rem 0.85rem;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.notification_item.warn {
|
||||
border-left-color: #c78300;
|
||||
}
|
||||
.notification_item.error {
|
||||
border-left-color: #b92525;
|
||||
}
|
||||
.notification_item .notification_title {
|
||||
font-weight: bold;
|
||||
line-height: 1.25;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.notification_item .notification_message {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.notification_item .notification_close {
|
||||
position: absolute;
|
||||
top: 0.35rem;
|
||||
right: 0.45rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #444;
|
||||
cursor: pointer;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
padding: 0.1rem 0.25rem;
|
||||
}
|
||||
|
||||
.wdg_frame {
|
||||
background-color: #FFF;
|
||||
border: 1px solid #ccc;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user