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:
Stefan
2026-07-03 20:22:05 +02:00
parent 1a3bf30442
commit 882e97509a
57 changed files with 2121 additions and 106 deletions
@@ -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) =>
+6 -2
View File
@@ -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);
+66
View File
@@ -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`.
+71
View File
@@ -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`.
+11
View File
@@ -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.
+34
View File
@@ -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;
}
+275
View File
@@ -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() ?? "";
}
}
+10
View File
@@ -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);
+25
View File
@@ -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);
}
+9
View File
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
namespace Fuchs.Notifications;
[Authorize]
public sealed class NotificationHub : Hub
{
}
+6
View File
@@ -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 =
+4
View File
@@ -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(
+119 -19
View File
@@ -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);
}
}
}
}
+5 -1
View File
@@ -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;
}
+10 -4
View File
@@ -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 "";
}
+5 -1
View File
@@ -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;
}
+18
View File
@@ -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>
}
+1
View File
@@ -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",
+10 -2
View File
@@ -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 { }
};
+55
View File
@@ -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;
+42 -1
View File
@@ -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
View File
@@ -1,3 +1,4 @@
$(document).ready(function () {
$fis.notifications.init();
$fis.ov();
});
+315
View File
@@ -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
View File
@@ -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",
+50
View File
@@ -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
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long