From 249a945f5e11005850b97908f25faf7986294bd1 Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 17 Sep 2026 21:17:23 +0200 Subject: [PATCH] feat: Implement admin-only specialties management interface - Added MailerAttachmentPolicy for validating email attachments against server rules. - Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface. - Created fis.specialties.scss for styling the specialties management UI. - Added German localization for specialties management in fis.specialties_txt_de.js. - Developed CSS for specialties management in fis.specialties.css. - Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files. - Created fds__invoice_file_backups table to store backup records of invoice files. --- .github/copilot-instructions.md | 2 +- CLAUDE.md | 2 +- CODEX.md | 2 +- Fuchs.Tests/AzureBlobStorageServiceTests.cs | 143 +++++ Fuchs.Tests/FuchsMailerClientTests.cs | 132 ++++ Fuchs.Tests/InvoiceFileBackupServiceTests.cs | 177 ++++++ Fuchs.Tests/ProcessWebComServiceTests.cs | 563 ++++++++++-------- Fuchs.Tests/SystemStatusServiceTests.cs | 148 +++++ Fuchs/Controllers/IntranetController.Admin.cs | 27 +- .../IntranetController.Reminder.cs | 10 +- .../IntranetController.Specialties.cs | 35 ++ Fuchs/Controllers/IntranetController.cs | 4 + Fuchs/Docs/ARCHITECTURE.md | 4 +- Fuchs/Docs/Concepts/admin-specialties.md | 73 +++ Fuchs/Docs/Concepts/admin-system-status.md | 19 +- .../Concepts/processweb-mailer-interface.md | 130 ++++ ...027-admin-invoice-file-backup-and-clear.md | 114 ++++ ...m-multipart-and-attachment-confirmation.md | 91 +++ ...outbound-mail-via-ocore-pwmailer-client.md | 101 ++++ Fuchs/Docs/INVOICE_LIFECYCLE.md | 18 +- Fuchs/Observability/FuchsTelemetry.cs | 9 + Fuchs/Program.cs | 20 +- Fuchs/Services/AzureBlobStorageService.cs | 54 ++ Fuchs/Services/FuchsMailerClient.cs | 34 ++ Fuchs/Services/IBlobStorageService.cs | 10 + Fuchs/Services/IComService.cs | 16 +- Fuchs/Services/IInvoiceFileBackupService.cs | 32 + Fuchs/Services/ISystemStatusService.cs | 8 +- Fuchs/Services/InvoiceFileBackupService.cs | 131 ++++ Fuchs/Services/MailerAttachmentPolicy.cs | 54 ++ Fuchs/Services/ProcessWebComService.cs | 225 +++---- Fuchs/Services/ProcessWebComSettings.cs | 27 +- Fuchs/Services/SystemStatusModels.cs | 6 + Fuchs/Services/SystemStatusService.cs | 68 ++- Fuchs/appsettings.Development.json | 2 +- Fuchs/appsettings.json | 9 +- Fuchs/bdlconfig.json | 19 + Fuchs/js/intranet/fis_main_go.js | 2 +- Fuchs/js/intranet/fis_main_menu.js | 37 +- Fuchs/js/intranet/fis_texts_gui_de.js | 1 + Fuchs/js/intranet/modules/fis.admin.js | 7 +- Fuchs/js/intranet/modules/fis.admin_txt_de.js | 2 + Fuchs/js/intranet/modules/fis.specialties.js | 97 +++ .../js/intranet/modules/fis.specialties.scss | 99 +++ .../modules/fis.specialties_txt_de.js | 26 + Fuchs/wwwroot/web/fis.admin.de.js | 9 +- Fuchs/wwwroot/web/fis.admin.de.min.js | 2 +- Fuchs/wwwroot/web/fis.js | 40 +- Fuchs/wwwroot/web/fis.min.js | 4 +- Fuchs/wwwroot/web/fis.specialties.css | 120 ++++ Fuchs/wwwroot/web/fis.specialties.de.js | 124 ++++ Fuchs/wwwroot/web/fis.specialties.de.min.js | 1 + Fuchs/wwwroot/web/fis.specialties.min.css | 1 + Fuchs/wwwroot/web/tools.js | 5 +- Fuchs_Database/FuchsDatabase.sqlproj | 2 + .../fds__admin_backupAndClearInvoiceFile.sql | 62 ++ .../dbo/Tables/fds__invoice_file_backups.sql | 13 + OCORE | 2 +- OCORE_web | 2 +- 59 files changed, 2720 insertions(+), 457 deletions(-) create mode 100644 Fuchs.Tests/FuchsMailerClientTests.cs create mode 100644 Fuchs.Tests/InvoiceFileBackupServiceTests.cs create mode 100644 Fuchs/Controllers/IntranetController.Specialties.cs create mode 100644 Fuchs/Docs/Concepts/admin-specialties.md create mode 100644 Fuchs/Docs/Concepts/processweb-mailer-interface.md create mode 100644 Fuchs/Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md create mode 100644 Fuchs/Docs/Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md create mode 100644 Fuchs/Docs/Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md create mode 100644 Fuchs/Services/FuchsMailerClient.cs create mode 100644 Fuchs/Services/IInvoiceFileBackupService.cs create mode 100644 Fuchs/Services/InvoiceFileBackupService.cs create mode 100644 Fuchs/Services/MailerAttachmentPolicy.cs create mode 100644 Fuchs/js/intranet/modules/fis.specialties.js create mode 100644 Fuchs/js/intranet/modules/fis.specialties.scss create mode 100644 Fuchs/js/intranet/modules/fis.specialties_txt_de.js create mode 100644 Fuchs/wwwroot/web/fis.specialties.css create mode 100644 Fuchs/wwwroot/web/fis.specialties.de.js create mode 100644 Fuchs/wwwroot/web/fis.specialties.de.min.js create mode 100644 Fuchs/wwwroot/web/fis.specialties.min.css create mode 100644 Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql create mode 100644 Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 69e058e..86accc6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -49,7 +49,7 @@ ## Services & Dependency Injection - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers. - - `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). + - `IComService` (email/SMS via ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). - Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async. - Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core. diff --git a/CLAUDE.md b/CLAUDE.md index b4119d3..90ee2b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ ## Services & Dependency Injection - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. -- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). +- Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). diff --git a/CODEX.md b/CODEX.md index 4508e36..cd1fd09 100644 --- a/CODEX.md +++ b/CODEX.md @@ -38,7 +38,7 @@ ## Services & Dependency Injection - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. -- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). +- Services: `IComService` (ProcessWeb Mailer API — contract at **https://processweb.de/api/mailer/fc**, read it and `Fuchs/Docs/Concepts/processweb-mailer-interface.md` before changing it. `ProcessWebComService` is a thin adapter over the OCORE_web `IPWMailerClientService` (`OCORE_web/comm/pwmailer/`, owns the HTTP contract; ADR 0029) with `FuchsMailerClient` overriding `ValidateMessage` for attachment limits; a send only counts as successful when the receipt's `attachmentsProcessed` matches the number sent (ADR 0028); honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). diff --git a/Fuchs.Tests/AzureBlobStorageServiceTests.cs b/Fuchs.Tests/AzureBlobStorageServiceTests.cs index 574ff25..82c3f35 100644 --- a/Fuchs.Tests/AzureBlobStorageServiceTests.cs +++ b/Fuchs.Tests/AzureBlobStorageServiceTests.cs @@ -172,4 +172,147 @@ public class AzureBlobStorageServiceTests Assert.Null(result); Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload."); } + + // ── CopyInvoicePdfToBackupAsync ──────────────────────────────────────────── + + [Fact] + public async Task CopyInvoicePdfToBackupAsync_NoClient_ReturnsNullWithoutThrowing() + { + var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger.Instance); + + Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf"); + + Assert.Null(result); + } + + [Fact] + public async Task CopyInvoicePdfToBackupAsync_NoExistingBlob_ReturnsNullWithoutCopying() + { + var (service, container, blob) = CreateMockedClientChain(); + blob.Setup(b => b.ExistsAsync(It.IsAny())) + .ReturnsAsync(Response.FromValue(false, Mock.Of())); + + var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger.Instance); + Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf"); + + Assert.Null(result); + blob.Verify(b => b.StartCopyFromUriAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + blob.Verify(b => b.DeleteIfExistsAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Source blob exists; the backup blob accepts a server-side copy whose operation completes + /// immediately. Returns the mocks needed for verification. + /// + private static (Mock service, Mock sourceBlob, Mock backupBlob, Mock copy) + CreateCopyChain(Uri sourceUri, Uri backupUri) + { + var (service, container, sourceBlob) = CreateMockedClientChain(); + sourceBlob.Setup(b => b.Uri).Returns(sourceUri); + sourceBlob.Setup(b => b.ExistsAsync(It.IsAny())) + .ReturnsAsync(Response.FromValue(true, Mock.Of())); + + var copy = new Mock(); + copy.Setup(c => c.WaitForCompletionAsync(It.IsAny())) + .ReturnsAsync(Response.FromValue(3L, Mock.Of())); + + var backupBlob = new Mock(); + backupBlob.Setup(b => b.Uri).Returns(backupUri); + backupBlob.Setup(b => b.StartCopyFromUriAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(copy.Object); + container.Setup(c => c.GetBlobClient(It.Is(n => n.StartsWith("INV1/backup_")))) + .Returns(backupBlob.Object); + + return (service, sourceBlob, backupBlob, copy); + } + + [Fact] + public async Task CopyInvoicePdfToBackupAsync_ExistingBlob_CopiesServerSideUnderBackupNameAndKeepsOriginal() + { + var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf"); + var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_20260917_120000_Rechnung_INV1.pdf"); + var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri); + + long succeeded = 0; + using var listener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies") + l.EnableMeasurementEvents(inst); + } + }; + listener.SetMeasurementEventCallback((_, value, _, _) => Interlocked.Add(ref succeeded, value)); + listener.Start(); + + var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger.Instance); + Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf"); + + Assert.Equal(backupUri, result); + // The bytes must never pass through the process: server-side copy from the source URI only. + backupBlob.Verify(b => b.StartCopyFromUriAsync(sourceUri, It.IsAny(), It.IsAny()), Times.Once); + copy.Verify(c => c.WaitForCompletionAsync(It.IsAny()), Times.Once); + sourceBlob.Verify(b => b.DownloadContentAsync(It.IsAny()), Times.Never); + backupBlob.Verify(b => b.UploadAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + // The original stays: it remains the current archive until the next regeneration overwrites it. + sourceBlob.Verify(b => b.DeleteIfExistsAsync( + It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.True(succeeded >= 1, "fuchs.blobstorage.backupcopies counter should have been incremented."); + } + + [Fact] + public async Task CopyInvoicePdfToBackupAsync_CopyFails_ReturnsNullAndIncrementsFailedCounter() + { + var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf"); + var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_x_Rechnung_INV1.pdf"); + var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri); + copy.Setup(c => c.WaitForCompletionAsync(It.IsAny())) + .ThrowsAsync(new RequestFailedException("copy aborted")); + + long failed = 0; + using var listener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed") + l.EnableMeasurementEvents(inst); + } + }; + listener.SetMeasurementEventCallback((_, value, _, _) => Interlocked.Add(ref failed, value)); + listener.Start(); + + var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger.Instance); + Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf"); + + Assert.Null(result); + Assert.True(failed >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented."); + } + + [Fact] + public async Task CopyInvoicePdfToBackupAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter() + { + var (service, container, blob) = CreateMockedClientChain(); + blob.Setup(b => b.ExistsAsync(It.IsAny())) + .ThrowsAsync(new RequestFailedException("simulated storage failure")); + + long delta = 0; + using var listener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed") + l.EnableMeasurementEvents(inst); + } + }; + listener.SetMeasurementEventCallback((_, value, _, _) => Interlocked.Add(ref delta, value)); + listener.Start(); + + var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger.Instance); + Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV2", "Rechnung_INV2.pdf"); + + Assert.Null(result); + Assert.True(delta >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented."); + } } diff --git a/Fuchs.Tests/FuchsMailerClientTests.cs b/Fuchs.Tests/FuchsMailerClientTests.cs new file mode 100644 index 0000000..0f31d6e --- /dev/null +++ b/Fuchs.Tests/FuchsMailerClientTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Fuchs.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using OCORE.web.pwmailer; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Covers the Fuchs override of : the Mailer +/// contract's attachment limits (count, per-file and per-message size, forbidden extensions) are +/// enforced before any HTTP request, on top of the base rules (required fields, empty file, +/// missing extension), and a compliant message still goes out. The transport is a scripted +/// handler that fails the test if it is reached when it must not be. +/// +public class FuchsMailerClientTests +{ + private sealed class ScriptedHandler : HttpMessageHandler + { + public int Calls; + public string Body = "{\"comStackID\":\"11111111-2222-3333-4444-555555555555\",\"attachmentsReceived\":1,\"attachmentsProcessed\":1}"; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body) }); + } + } + + private sealed class Factory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + public Factory(HttpMessageHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); + } + + private static (FuchsMailerClient client, ScriptedHandler handler) Make(MailerAttachmentLimits? limits = null) + { + var handler = new ScriptedHandler(); + var options = Options.Create(new PWMailerClientOptions + { + BaseUrl = "https://mailer.test/api/mailer", Account = "acct", Token = "tok", + ServerId = "11111111-2222-3333-4444-555555555555", RetryDelayMilliseconds = 0 + }); + var settings = Options.Create(new ProcessWebComSettings { Attachments = limits ?? new MailerAttachmentLimits() }); + return (new FuchsMailerClient(options, settings, new Factory(handler), NullLogger.Instance), handler); + } + + private static PWMailerMessage Message(params PWMailerAttachment[] attachments) + { + var m = new PWMailerMessage { To = "a@example.com", Subject = "Hi", Body = "

x

", Reference = "R1" }; + m.Attachments.AddRange(attachments); + return m; + } + + public static IEnumerable LimitViolations() + { + yield return [new PWMailerAttachment[] { new("run.EXE", [1]) }, "forbidden extension (.exe)"]; + yield return [new PWMailerAttachment[] { new("script.js", [1]) }, "forbidden extension (.js)"]; + yield return [Enumerable.Range(1, 11).Select(i => new PWMailerAttachment($"f{i}.pdf", [1])).ToArray(), "Too many attachments: 11 (max 10)"]; + } + + [Theory] + [MemberData(nameof(LimitViolations))] + public async Task PushAsync_AttachmentViolatesContractLimits_RefusedBeforeSending(PWMailerAttachment[] attachments, string expected) + { + var (client, handler) = Make(); + + var result = await client.PushAsync(Message(attachments)); + + Assert.False(result.Success); + Assert.Equal(0, result.StatusCode); + Assert.Equal(0, result.Attempts); + Assert.Contains(expected, result.Error); + Assert.Equal(0, handler.Calls); + } + + [Fact] + public async Task PushAsync_PerFileAndPerMessageSizeLimits_AreEnforcedFromSettings() + { + var (client, handler) = Make(new MailerAttachmentLimits { MaxFileBytes = 100, MaxMessageBytes = 150, MaxCount = 10 }); + + var tooBig = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[101]))); + Assert.Contains("'a.pdf' is 101 bytes (max 100)", tooBig.Error); + + var total = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[100]), new PWMailerAttachment("b.pdf", new byte[51]))); + Assert.Contains("total 151 bytes (max 150", total.Error); + + Assert.Equal(0, handler.Calls); + } + + [Theory] + [InlineData("leer.pdf", new byte[0], "is empty")] + [InlineData("noext", new byte[] { 1 }, "no file extension")] + public async Task PushAsync_BaseContractRules_StillAppliedThroughOverride(string name, byte[] bytes, string expected) + { + var (client, handler) = Make(); + + var result = await client.PushAsync(Message(new PWMailerAttachment(name, bytes))); + + Assert.False(result.Success); + Assert.Contains(expected, result.Error); + Assert.Equal(0, handler.Calls); + } + + [Theory] + [InlineData("Rechnung.pdf")] + [InlineData("export.CSV")] + [InlineData("archive.7z")] + public async Task PushAsync_AllowedAttachment_IsSent(string name) + { + var (client, handler) = Make(); + + var result = await client.PushAsync(Message(new PWMailerAttachment(name, [1, 2]))); + + Assert.True(result.Success, result.Error); + Assert.Equal(1, handler.Calls); + Assert.Equal(1, result.Value!.AttachmentsProcessed); + } + + [Fact] + public void MailerAttachmentPolicy_NoAttachments_NoViolation() + { + Assert.Empty(MailerAttachmentPolicy.Validate(Array.Empty(), new MailerAttachmentLimits())); + } +} diff --git a/Fuchs.Tests/InvoiceFileBackupServiceTests.cs b/Fuchs.Tests/InvoiceFileBackupServiceTests.cs new file mode 100644 index 0000000..0367626 --- /dev/null +++ b/Fuchs.Tests/InvoiceFileBackupServiceTests.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Fuchs.intranet; +using Fuchs.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Moq; +using OCORE.security; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Covers : the invoice-not-found +/// short-circuit, that a blob backup-rename failure never blocks the DB-side backup/clear (best +/// effort per Docs/Decisions/0027), and that the DB call failing is reported without throwing. +/// No SQL Server: the DB-clear path uses an unconfigured connection so the write fails fast inside +/// the OCORE helper, mirroring InvoiceServiceStoreFileTests. +/// +public class InvoiceFileBackupServiceTests +{ + private sealed class RecordingLogger : ILogger + { + public sealed record Entry(LogLevel Level, Exception? Exception, string Message); + public readonly List Entries = new(); + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => + Entries.Add(new Entry(logLevel, exception, formatter(state, exception))); + } + + // A refused local port: the write fails inside the OCORE helper's own try/catch (returning an + // Exception string) instead of hanging on a connect timeout. + private const string UnreachableConnectionString = + "Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True"; + + private static FdsInvoiceData Invoice(string id = "W7VAL", bool isFinal = true, bool isExternal = false, bool hasFile = true) => new() + { + InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary + { + ["Id"] = id, + ["InvoiceId"] = "R2026-0258", + ["DocumentName"] = $"Rechnung_{id}.pdf", + ["IsFinal"] = isFinal, + ["IsExternal"] = isExternal, + ["hasFile"] = hasFile, + }), + IsDraft = !isFinal, + }; + + private static (InvoiceFileBackupService svc, RecordingLogger log, Mock invoices, Mock blob) + MakeService(FdsInvoiceData? invoice, string? connectionString = null) + { + var log = new RecordingLogger(); + var invoices = new Mock(); + invoices.Setup(i => i.LoadInvoiceAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(invoice ?? new FdsInvoiceData()); + var blob = new Mock(); + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString + }).Build(); + var svc = new InvoiceFileBackupService(new Fuchs_intranet(config), invoices.Object, blob.Object, log); + return (svc, log, invoices, blob); + } + + // LoadInvoiceAsync never yields a null registration for an unknown id — it yields an empty + // dictionary (FirstRow.toObjectDictionary()), i.e. an invoice whose Id is "". Both shapes must + // be recognised as "not found" and never fall through to the IsDraft check as "not_final". + public static IEnumerable NotFoundShapes() => + [ + [ new FdsInvoiceData() ], + [ new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary()) } ], + ]; + + [Theory] + [MemberData(nameof(NotFoundShapes))] + public async Task BackupAndClearAsync_InvoiceNotFound_ReturnsFailureWithoutTouchingBlobOrDb(FdsInvoiceData loaded) + { + var (svc, log, invoices, blob) = MakeService(loaded); + + var result = await svc.BackupAndClearAsync("MISSING", "user1", new DatabaseSecurity("user1", null)); + + Assert.False(result.Success); + Assert.Equal("invoice_not_found", result.Reason); + Assert.Null(result.BlobBackupUri); + blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + var entry = Assert.Single(log.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("not found", entry.Message); + } + + // ADR 0025's regeneration precondition is load-bearing: clearing a draft's file would let the + // next read persist a draft render that then blocks the real final PDF forever; an external + // (MFR) document can't be regenerated at all. Both must be refused before anything is touched. + [Theory] + [InlineData(false, false, "not_final")] + [InlineData(true, true, "external")] + [InlineData(false, true, "not_final")] + public async Task BackupAndClearAsync_DraftOrExternalInvoice_RefusesWithoutTouchingBlobOrDb( + bool isFinal, bool isExternal, string expectedReason) + { + var (svc, log, invoices, blob) = MakeService(Invoice(isFinal: isFinal, isExternal: isExternal), UnreachableConnectionString); + + var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null)); + + Assert.False(result.Success); + Assert.Equal(expectedReason, result.Reason); + Assert.Null(result.BlobBackupUri); + blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + var entry = Assert.Single(log.Entries); + Assert.Equal(LogLevel.Warning, entry.Level); + Assert.Contains("refused", entry.Message); + Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error); + } + + [Fact] + public async Task BackupAndClearAsync_NoStoredFile_ReturnsNoFileWithoutCopyingBlobOrTouchingDb() + { + // A repeated click on an already-cleared invoice must not leave another backup_* blob + // behind: fds__getInvoice's hasFile settles it before the copy and before the proc. + var (svc, log, invoices, blob) = MakeService(Invoice(hasFile: false), UnreachableConnectionString); + + var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null)); + + Assert.False(result.Success); + Assert.Equal("no_file", result.Reason); + Assert.Null(result.BlobBackupUri); + blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error); // the proc was never called + Assert.Contains(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("no file stored")); + } + + [Fact] + public async Task BackupAndClearAsync_BlobCopySucceeds_DbFails_ReportsDbErrorWithBackupUri() + { + // The backup copy is taken BEFORE the DB clear (so no regeneration can overwrite the + // original first) and the original blob is kept; a DB failure (here: no real SQL Server) + // therefore leaves the archive intact plus a spare copy, which the result still reports. + var backupUri = new Uri("https://test.blob.core.windows.net/invoices/W7VAL/backup_x.pdf"); + var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString); + blob.Setup(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny())) + .ReturnsAsync(backupUri); + + var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null)); + + Assert.False(result.Success); + Assert.Equal("db_error", result.Reason); + Assert.Equal(backupUri, result.BlobBackupUri); + blob.Verify(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny()), Times.Once); + Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed")); + } + + [Fact] + public async Task BackupAndClearAsync_BlobCopyThrows_StillAttemptsDbClearAndReturnsDbOutcome() + { + var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString); + blob.Setup(b => b.CopyInvoicePdfToBackupAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("blob boom")); + + var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null)); + + // The blob exception is swallowed and logged; the DB step still runs (and fails here for + // lack of a SQL Server) rather than the exception escaping BackupAndClearAsync. + Assert.False(result.Success); + Assert.Equal("db_error", result.Reason); + Assert.Null(result.BlobBackupUri); + Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("blob backup-copy threw")); + Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed")); + } +} diff --git a/Fuchs.Tests/ProcessWebComServiceTests.cs b/Fuchs.Tests/ProcessWebComServiceTests.cs index ce4ca4b..e4b0014 100644 --- a/Fuchs.Tests/ProcessWebComServiceTests.cs +++ b/Fuchs.Tests/ProcessWebComServiceTests.cs @@ -1,67 +1,73 @@ -using System; +using System; using System.Collections.Generic; using System.Diagnostics.Metrics; using System.IO; using System.Linq; -using System.Net; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Fuchs.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Moq; using Newtonsoft.Json.Linq; +using OCORE.web.pwmailer; using Xunit; namespace Fuchs.Tests; /// -/// Tests for the outbound communication service. Cover both intentionally -/// succeeding (API 200) and intentionally failing (invalid email, disabled, -/// API 500) paths, plus the attachment payload contract and metric emission. +/// Tests for the outbound communication service as an adapter over the OCORE_web +/// (ADR 0029). Cover both intentionally succeeding and +/// intentionally failing paths (invalid email, disabled, API error, duplicate, refused by +/// pre-validation, attachment count not confirmed), the handed to +/// the client, the audit log written on every path, and metric emission. The HTTP contract itself +/// (JSON vs multipart, unnamed envelope field, attachmentCount) is the client's responsibility and +/// is tested in OCORE_webTests/comm/PWMailerClientServiceTests.cs. /// /// Fuchs_intranet is passed as null: it is only touched by the audit-log write, /// which is wrapped in try/catch and therefore harmless in a unit test. /// public class ProcessWebComServiceTests { + private const string FuchsMeterName = "Fuchs.Intranet"; + // ── Test doubles ──────────────────────────────────────────────────────── - private sealed class StubHandler : HttpMessageHandler - { - private readonly HttpStatusCode _status; - private readonly string _body; - public string? LastRequestBody { get; private set; } - public int CallCount { get; private set; } + private static PWMailerResult Accepted(int received = 0, int processed = 0, string comStackId = "cs-1") => + new() { StatusCode = 200, Value = new PWMailerPushReceipt(comStackId, received, processed), Attempts = 1, Operation = "push_com" }; - public StubHandler(HttpStatusCode status, string body = "OK") + private static PWMailerResult Failed(int status, string error) => + new() { StatusCode = status, Error = error, Attempts = 1, Operation = "push_com" }; + + /// The client refused the call before sending (validation, options, token): status 0, no attempt. + private static PWMailerResult Refused(string error) => + new() { StatusCode = 0, Error = error, Attempts = 0, Operation = "push_com", Exception = new ArgumentException(error) }; + + private sealed class MailerStub + { + public readonly Mock Mock = new(MockBehavior.Loose); + public readonly List Pushed = new(); + public int CallCount => Pushed.Count; + + public MailerStub(PWMailerResult? push = null) { - _status = status; - _body = body; + var result = push ?? Accepted(); + Mock.Setup(m => m.PushAsync(It.IsAny(), It.IsAny())) + .Callback((m, _) => Pushed.Add(m)) + .ReturnsAsync(result); } - protected override async Task SendAsync( - HttpRequestMessage request, CancellationToken cancellationToken) + /// Answers like a Mailer that processed every file it received. + public static MailerStub Echoing() { - CallCount++; - LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken); - return new HttpResponseMessage(_status) { Content = new StringContent(_body) }; + var stub = new MailerStub(); + stub.Mock.Setup(m => m.PushAsync(It.IsAny(), It.IsAny())) + .Callback((m, _) => stub.Pushed.Add(m)) + .ReturnsAsync((PWMailerMessage m, CancellationToken _) => Accepted(m.Attachments.Count, m.Attachments.Count)); + return stub; } } - private sealed class ThrowingHandler : HttpMessageHandler - { - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - => throw new HttpRequestException("simulated transport failure"); - } - - private sealed class StubHttpClientFactory : IHttpClientFactory - { - private readonly HttpMessageHandler _handler; - public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler; - public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); - } - /// /// Captures what would be written to fds__logEmail without hitting a database, by /// overriding the audit-log write. Lets tests assert that every send path is logged and that @@ -71,146 +77,128 @@ public class ProcessWebComServiceTests { public readonly List<(bool success, List log)> Entries = new(); public readonly List Configs = new(); + public readonly List Guids = new(); - public CapturingComService( - IOptions settings, - IOptions emailSettings, - IHttpClientFactory httpClientFactory) - : base(NullLogger.Instance, intranet: null!, settings, emailSettings, httpClientFactory) - { - } + public CapturingComService(IOptions settings, IOptions emailSettings, + IPWMailerClientService mailer) + : base(NullLogger.Instance, intranet: null!, settings, emailSettings, mailer) { } protected override Task WriteAuditLogAsync(string reference, string guid, string config, DateTime sent, bool success, IEnumerable errors) { Entries.Add((success, errors.ToList())); Configs.Add(config); + Guids.Add(guid); return Task.CompletedTask; } } - private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null) - { - var settings = Options.Create(new ProcessWebComSettings + private static IOptions Settings(bool enabled = true, string accountId = "acct", string token = "tok", string serverId = "") => + Options.Create(new ProcessWebComSettings { - Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" + Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token, ServerId = serverId }); - var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }); - return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler)); - } - private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null) - { - var settings = Options.Create(new ProcessWebComSettings - { - Enabled = enabled, - BaseUrl = "https://mailer.test", - AccountId = "acct", - Token = "tok" - }); - var emailSettings = Options.Create(new FuchsEmailSettings - { - OverrideRecipient = overrideRecipient - }); - return new ProcessWebComService( - NullLogger.Instance, - intranet: null!, - settings, - emailSettings, - new StubHttpClientFactory(handler)); - } + private static CapturingComService CreateCapturing(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) => + new(Settings(enabled), Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object); + + private static ProcessWebComService CreateService(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) => + new(NullLogger.Instance, intranet: null!, Settings(enabled), + Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object); // ── Success path ───────────────────────────────────────────────────────── [Fact] - public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce() + public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPushesOnce() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); + var mailer = new MailerStub(); + var svc = CreateService(mailer); bool result = await svc.SendEmailAsync("inv_1", "Subject", "

hi

", "kunde@example.de", "Kunde"); Assert.True(result); - Assert.Equal(1, handler.CallCount); + Assert.Equal(1, mailer.CallCount); } // ── Failure paths ────────────────────────────────────────────────────────── [Fact] public async Task SendEmailAsync_ApiError_ReturnsFalse() { - var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom"); - var svc = CreateService(handler); + var mailer = new MailerStub(Failed(500, "boom")); + var svc = CreateService(mailer); bool result = await svc.SendEmailAsync("inv_2", "Subject", "

hi

", "kunde@example.de", "Kunde"); Assert.False(result); - Assert.Equal(1, handler.CallCount); + Assert.Equal(1, mailer.CallCount); } [Theory] [InlineData("not-an-email")] [InlineData("")] [InlineData("missing@")] - public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail) + public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingMailer(string badEmail) { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); + var mailer = new MailerStub(); + var svc = CreateService(mailer); bool result = await svc.SendEmailAsync("inv_3", "Subject", "

hi

", badEmail, "Kunde"); Assert.False(result); - Assert.Equal(0, handler.CallCount); // never reached the API + Assert.Equal(0, mailer.CallCount); } [Fact] - public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi() + public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingMailer() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler, enabled: false); + var mailer = new MailerStub(); + var svc = CreateService(mailer, enabled: false); bool result = await svc.SendEmailAsync("inv_4", "Subject", "

hi

", "kunde@example.de", "Kunde"); Assert.False(result); - Assert.Equal(0, handler.CallCount); + Assert.Equal(0, mailer.CallCount); } - // ── Audit logging: every attempt is logged; failures carry the service response ─ + // ── Audit logging: every attempt is logged; failures carry the client's error ─ [Fact] - public async Task SendEmailAsync_Success_LogsSuccessfulAttempt() + public async Task SendEmailAsync_Success_LogsSuccessfulAttemptWithComStackId() { - var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK)); + var svc = CreateCapturing(new MailerStub(Accepted(comStackId: "cs-42"))); await svc.SendEmailAsync("inv_log_ok", "S", "

x

", "kunde@example.de", "Kunde"); var entry = Assert.Single(svc.Entries); Assert.True(entry.success); + Assert.Equal("cs-42", Assert.Single(svc.Guids)); // what getComStatus/getCom accept } [Fact] - public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse() + public async Task SendEmailAsync_ApiError_LogsFailureIncludingStatusAndError() { - var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded")); + var svc = CreateCapturing(new MailerStub(Failed(500, "mailer rejected: quota exceeded"))); bool result = await svc.SendEmailAsync("inv_log_api", "S", "

x

", "kunde@example.de", "Kunde"); Assert.False(result); var entry = Assert.Single(svc.Entries); Assert.False(entry.success); - Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response - Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status + Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded") && l.Contains("500")); } [Fact] - public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail() + public async Task SendEmailAsync_ClientThrows_LogsFailureIncludingExceptionDetail() { - var svc = CreateCapturing(new ThrowingHandler()); + var mailer = new MailerStub(); + mailer.Mock.Setup(m => m.PushAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("simulated client failure")); + var svc = CreateCapturing(mailer); bool result = await svc.SendEmailAsync("inv_log_ex", "S", "

x

", "kunde@example.de", "Kunde"); Assert.False(result); var entry = Assert.Single(svc.Entries); Assert.False(entry.success); - Assert.Contains(entry.log, l => l.Contains("Exception while sending")); - Assert.Contains(entry.log, l => l.Contains("simulated transport failure")); + Assert.Contains(entry.log, l => l.Contains("Exception while sending") && l.Contains("simulated client failure")); } [Theory] @@ -218,7 +206,7 @@ public class ProcessWebComServiceTests [InlineData("")] public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail) { - var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK)); + var svc = CreateCapturing(new MailerStub()); bool result = await svc.SendEmailAsync("inv_log_bad", "S", "

x

", badEmail, "Kunde"); @@ -231,7 +219,7 @@ public class ProcessWebComServiceTests [Fact] public async Task SendEmailAsync_Disabled_LogsFailedAttempt() { - var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false); + var svc = CreateCapturing(new MailerStub(), enabled: false); await svc.SendEmailAsync("inv_log_dis", "S", "

x

", "kunde@example.de", "Kunde"); @@ -317,18 +305,19 @@ public class ProcessWebComServiceTests public static IEnumerable AllAuditPaths() => [ - [ "success", HttpStatusCode.OK, true, "kunde@example.de" ], - [ "api-error", HttpStatusCode.InternalServerError, true, "kunde@example.de" ], - [ "disabled", HttpStatusCode.OK, false, "kunde@example.de" ], - [ "invalid-email", HttpStatusCode.OK, true, "not-an-email" ], + [ "success", 200, true, "kunde@example.de" ], + [ "api-error", 500, true, "kunde@example.de" ], + [ "disabled", 200, false, "kunde@example.de" ], + [ "invalid-email", 200, true, "not-an-email" ], ]; [Theory] [MemberData(nameof(AllAuditPaths))] public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments( - string path, HttpStatusCode status, bool enabled, string email) + string path, int status, bool enabled, string email) { - var svc = CreateCapturing(new StubHandler(status), enabled: enabled); + var mailer = status == 200 ? MailerStub.Echoing() : new MailerStub(Failed(status, "err")); + var svc = CreateCapturing(mailer, enabled: enabled); var att = new Dictionary { ["Mahnung.pdf"] = [9, 9] }; await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "

x

", email, "Kunde", att); @@ -343,7 +332,7 @@ public class ProcessWebComServiceTests [Fact] public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject() { - var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), overrideRecipient: "dev-inbox@example.test"); + var svc = CreateCapturing(new MailerStub(), overrideRecipient: "dev-inbox@example.test"); await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "

x

", "kunde@example.de", "Kunde", null, cc: "cc@example.de"); @@ -357,27 +346,24 @@ public class ProcessWebComServiceTests [Fact] public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); + var mailer = new MailerStub(); + var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test"); bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "

hi

", "realcustomer@example.de", "Kunde"); Assert.True(result); - Assert.Equal(1, handler.CallCount); - var json = JObject.Parse(handler.LastRequestBody!); - Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString()); + Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To); } [Fact] public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); + var mailer = new MailerStub(); + var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test"); await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "

hi

", "realcustomer@example.de", "Kunde"); - var json = JObject.Parse(handler.LastRequestBody!); - string subject = json["communication"]!["subject"]!.ToString(); + string subject = Assert.Single(mailer.Pushed).Subject; Assert.Contains("realcustomer@example.de", subject); Assert.Contains("Rechnung 123", subject); } @@ -385,84 +371,69 @@ public class ProcessWebComServiceTests [Fact] public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); + var mailer = new MailerStub(); + var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test"); bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "

hi

", "not-an-email", "Kunde"); Assert.True(result); - Assert.Equal(1, handler.CallCount); - var json = JObject.Parse(handler.LastRequestBody!); - Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString()); + Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To); } [Fact] public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); + var mailer = new MailerStub(); + var svc = CreateService(mailer); await svc.SendEmailAsync("inv_ov4", "Subject", "

hi

", "realcustomer@example.de", "Kunde"); - var json = JObject.Parse(handler.LastRequestBody!); - Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString()); + Assert.Equal("realcustomer@example.de", Assert.Single(mailer.Pushed).To); } // ── Cc recipient ("Zusatzempfänger") ───────────────────────────────────────── [Fact] - public async Task SendEmailAsync_CcProvided_IsIncludedInPayload() + public async Task SendEmailAsync_CcProvided_IsIncludedInMessage() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); + var mailer = new MailerStub(); + var svc = CreateService(mailer); - await svc.SendEmailAsync("inv_cc1", "Subject", "

hi

", "kunde@example.de", "Kunde", - cc: "zusatz@example.de"); + await svc.SendEmailAsync("inv_cc1", "Subject", "

hi

", "kunde@example.de", "Kunde", cc: "zusatz@example.de"); - var json = JObject.Parse(handler.LastRequestBody!); - Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString()); + Assert.Equal("zusatz@example.de", Assert.Single(mailer.Pushed).Cc); } [Fact] - public async Task SendEmailAsync_CcOmitted_IsNullInPayload() + public async Task SendEmailAsync_CcOmitted_IsNullInMessage() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); + var mailer = new MailerStub(); + var svc = CreateService(mailer); await svc.SendEmailAsync("inv_cc2", "Subject", "

hi

", "kunde@example.de", "Kunde"); - var json = JObject.Parse(handler.LastRequestBody!); - Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type); + Assert.Null(Assert.Single(mailer.Pushed).Cc); } [Fact] public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test"); + var mailer = new MailerStub(); + var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test"); - await svc.SendEmailAsync("inv_cc3", "Subject", "

hi

", "realcustomer@example.de", "Kunde", - cc: "zusatz@example.de"); + await svc.SendEmailAsync("inv_cc3", "Subject", "

hi

", "realcustomer@example.de", "Kunde", cc: "zusatz@example.de"); - var json = JObject.Parse(handler.LastRequestBody!); - Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString()); - Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type); + var m = Assert.Single(mailer.Pushed); + Assert.Equal("dev-inbox@example.test", m.To); + Assert.Null(m.Cc); } // ── Override enforcement across every real appsettings*.json environment ─── - // Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core - // layers them (base file + optional environment-specific overlay). These files - // are copied into this test assembly's output directory via the Fuchs project - // reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content - // with CopyToOutputDirectory). This proves the safety net holds for whichever - // environment/appsettings file ends up "active" at runtime - not just a - // hand-typed literal - so a future edit that silently breaks the override key - // or its value in any appsettings*.json file would fail this test. - // - // Note: this API has a single "communication.to" field - there is no distinct - // to/cc/bcc concept anywhere in the codebase (see ProcessWebComService. - // SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully - // satisfied by asserting that field no longer carries the original recipient - // once an override is configured. + // Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core layers them (base + // file + optional environment-specific overlay). These files are copied into this test + // assembly's output directory via the Fuchs project reference. This proves the safety net holds + // for whichever environment/appsettings file ends up "active" at runtime — not just a hand-typed + // literal — so a future edit that silently breaks the override key or its value in any + // appsettings*.json file would fail this test. public static IEnumerable AppSettingsEnvironments() { yield return new object[] { "" }; // base appsettings.json only ("Production"-like default) @@ -489,8 +460,8 @@ public class ProcessWebComServiceTests public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName) { string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName); - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler, overrideRecipient: overrideRecipient); + var mailer = new MailerStub(); + var svc = CreateService(mailer, overrideRecipient: overrideRecipient); const string originalRecipient = "realcustomer@tenant-owner.example"; bool result = await svc.SendEmailAsync( @@ -498,134 +469,226 @@ public class ProcessWebComServiceTests "Subject", "

hi

", originalRecipient, "Kunde"); Assert.True(result); - Assert.Equal(1, handler.CallCount); - var json = JObject.Parse(handler.LastRequestBody!); - string sentRecipient = json["communication"]!["to"]!.ToString(); - + string sentRecipient = Assert.Single(mailer.Pushed).To; if (!string.IsNullOrWhiteSpace(overrideRecipient)) { - // Override configured for this environment: the real recipient (to/cc/bcc) - // must be fully cleared and replaced by the override - never leaked. Assert.Equal(overrideRecipient, sentRecipient); Assert.NotEqual(originalRecipient, sentRecipient); } else { - // No override configured for this environment: real recipient is used normally. Assert.Equal(originalRecipient, sentRecipient); } } - // ── Attachment payload contract ──────────────────────────────────────────── + // ── The message handed to the PWMailer client ───────────────────────────── [Fact] - public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload() + public async Task SendEmailAsync_MapsEnvelopeFieldsAndAttachmentsOntoPWMailerMessage() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); - byte[] pdf = { 1, 2, 3, 4 }; - var attachments = new Dictionary { ["Rechnung.pdf"] = pdf }; + var mailer = MailerStub.Echoing(); + var svc = CreateService(mailer); + byte[] pdf = { 0x25, 0x50, 0x44, 0x46 }; + byte[] xml = { 0x3C, 0x3F }; + var attachments = new Dictionary { ["Rechnung R2026-0119.pdf"] = pdf, ["zugferd.xml"] = xml }; - bool result = await svc.SendEmailAsync("inv_5", "Subject", "

hi

", "kunde@example.de", "Kunde", attachments); + bool result = await svc.SendEmailAsync("inv_map", "Subject", "

hi

", "kunde@example.de", "Kunde", attachments, cc: "cc@example.de"); Assert.True(result); - Assert.NotNull(handler.LastRequestBody); - var json = JObject.Parse(handler.LastRequestBody!); - var att = (JArray)json["communication"]!["attachments"]!; - Assert.Single(att); - Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString()); - Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString()); - Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString()); + var m = Assert.Single(mailer.Pushed); + Assert.Equal("e", m.ComType); + Assert.Equal("inv_map", m.Reference); + Assert.Equal("kunde@example.de", m.To); + Assert.Equal("cc@example.de", m.Cc); + Assert.Equal("Subject", m.Subject); + Assert.StartsWith("

hi

", m.Body); // signature is appended after the body + Assert.Null(m.AttachmentCount); // null ⇒ client sends the real count + Assert.Equal(2, m.Attachments.Count); + Assert.Equal(pdf, m.Attachments.Single(a => a.FileName == "Rechnung R2026-0119.pdf").Bytes); + Assert.Equal(xml, m.Attachments.Single(a => a.FileName == "zugferd.xml").Bytes); + } + + // ── Response checks ──────────────────────────────────────────────────────── + [Theory] + [InlineData(1, 0)] + [InlineData(0, 0)] + public async Task SendEmailAsync_AttachmentsNotConfirmedProcessed_ReturnsFalseAndAuditsCounts(int received, int processed) + { + // A 200 is not enough when files were sent: the Mailer must report every one as processed. + var svc = CreateCapturing(new MailerStub(Accepted(received, processed, "cs-9"))); + var att = new Dictionary { ["Rechnung.pdf"] = [1, 2, 3] }; + + bool result = await svc.SendEmailAsync("inv_mismatch", "S", "

x

", "kunde@example.de", "Kunde", att); + + Assert.False(result); + var entry = Assert.Single(svc.Entries); + Assert.False(entry.success); + Assert.Contains(entry.log, l => l.Contains("not all 1 attachment(s)") + && l.Contains($"attachmentsReceived={received}") && l.Contains($"attachmentsProcessed={processed}")); + Assert.Equal("cs-9", Assert.Single(svc.Guids)); // still traceable at the Mailer + } + + [Fact] + public async Task SendEmailAsync_AttachmentsMismatch_IncrementsFailedCounterWithReason() + { + string? reason = null; + using var listener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed") + l.EnableMeasurementEvents(inst); + } + }; + listener.SetMeasurementEventCallback((_, _, tags, _) => + { + foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString(); + }); + listener.Start(); + + var svc = CreateService(new MailerStub(Accepted(1, 0))); + await svc.SendEmailAsync("inv_mm_metric", "S", "

x

", "kunde@example.de", "Kunde", + new Dictionary { ["a.pdf"] = [1] }); + + Assert.Equal("attachments-mismatch", reason); + } + + [Fact] + public async Task SendEmailAsync_Conflict409_IsSuccessBecauseThePendingDuplicateWillBeSent() + { + // A resend of an identical message while the first is still pending: the Mailer keeps the + // first entry and delivers it — the caller (e.g. req/resend, or a double click) must see a + // successful send, with the deduplication recorded in the audit log. + var svc = CreateCapturing(new MailerStub(Failed(409, "duplicate of pending entry"))); + + bool result = await svc.SendEmailAsync("inv_dup", "S", "

x

", "kunde@example.de", "Kunde"); + + Assert.True(result); + var entry = Assert.Single(svc.Entries); + Assert.True(entry.success); + Assert.Contains(entry.log, l => l.Contains("still-pending") && l.Contains("409") && l.Contains("duplicate of pending entry")); + } + + [Fact] + public async Task SendEmailAsync_RefusedByClientPreValidation_ReturnsFalseAndAuditsEachRule() + { + // The client refuses before sending (status 0, no attempt) with "; "-joined rule messages — + // e.g. Fuchs' attachment limits (FuchsMailerClient) or the contract's required fields. + var svc = CreateCapturing(new MailerStub(Refused("Attachment 'big.pdf' is 11,000,000 bytes (max 10,485,760); attachment 'x' has no file extension"))); + + bool result = await svc.SendEmailAsync("inv_refused", "S", "

x

", "kunde@example.de", "Kunde", + new Dictionary { ["big.pdf"] = [1] }); + + Assert.False(result); + var entry = Assert.Single(svc.Entries); + Assert.False(entry.success); + Assert.Equal(2, entry.log.Count); + Assert.Contains(entry.log, l => l.StartsWith("Attachment 'big.pdf'")); + Assert.Contains(entry.log, l => l.Contains("no file extension")); + } + + [Fact] + public async Task SendEmailAsync_RefusedBecauseClientNotUsable_ReturnsFalseWithClientConfigReason() + { + // Same shape as a validation refusal (status 0, no attempt) but no ArgumentException: the + // client could not resolve its token / options. Must not be reported as caller validation. + string? reason = null; + using var listener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed") + l.EnableMeasurementEvents(inst); + } + }; + listener.SetMeasurementEventCallback((_, _, tags, _) => + { + foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString(); + }); + listener.Start(); + + var svc = CreateCapturing(new MailerStub(new PWMailerResult + { + StatusCode = 0, Attempts = 0, Operation = "push_com", + Error = "PWMailer token could not be loaded from Key Vault secret 'Fuchs--Mailer--Token': timeout" + })); + + bool result = await svc.SendEmailAsync("inv_cfg", "S", "

x

", "kunde@example.de", "Kunde"); + + Assert.False(result); + Assert.Equal("client-config", reason); + var entry = Assert.Single(svc.Entries); + Assert.False(entry.success); + Assert.Contains(entry.log, l => l.StartsWith("Mailer client not usable") && l.Contains("Key Vault")); } // ── SMS ──────────────────────────────────────────────────────────────────── [Fact] - public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue() + public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrueAndPushesComTypeS() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); + var mailer = new MailerStub(); + var svc = CreateService(mailer); bool result = await svc.SendSmsAsync("01700000000", "Code 1234"); Assert.True(result); - Assert.Equal(1, handler.CallCount); + var m = Assert.Single(mailer.Pushed); + Assert.Equal("s", m.ComType); + Assert.Equal("01700000000", m.To); + Assert.Equal("Code 1234", m.Body); } [Fact] - public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi() + public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingMailer() { - var handler = new StubHandler(HttpStatusCode.OK); - var svc = CreateService(handler); + var mailer = new MailerStub(); + var svc = CreateService(mailer); bool result = await svc.SendSmsAsync("", "Code 1234"); Assert.False(result); - Assert.Equal(0, handler.CallCount); + Assert.Equal(0, mailer.CallCount); } - // ── Credential verification (GET /mailer/fc?fn=verify) ───────────────────── - private sealed class CapturingRequestHandler : HttpMessageHandler - { - private readonly HttpStatusCode _status; - private readonly string _body; - public HttpRequestMessage? LastRequest { get; private set; } + // ── Credential verification (client VerifyAsync) ──────────────────────────── + private static PWMailerResult VerifyResult(bool ok, string account = "acc-123", string? serverId = null) => + new() { StatusCode = 200, Value = new PWMailerVerifyResult(ok, account, serverId), Attempts = 1, Operation = "verify" }; - public CapturingRequestHandler(HttpStatusCode status, string body) { _status = status; _body = body; } - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - LastRequest = request; - return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) }); - } - } + private static ProcessWebComService VerifyService(Mock mailer, string accountId = "acct", string token = "tok", string serverId = "") => + new(NullLogger.Instance, intranet: null!, Settings(true, accountId, token, serverId), + Options.Create(new FuchsEmailSettings()), mailer.Object); [Fact] public async Task VerifyCredentialsAsync_MailerConfirms_ReturnsOkWithAccount() { - var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\"}"); - var svc = new ProcessWebComService( - NullLogger.Instance, intranet: null!, - Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }), - Options.Create(new FuchsEmailSettings()), - new StubHttpClientFactory(handler)); + var mailer = new Mock(); + mailer.Setup(m => m.VerifyAsync(null, It.IsAny())).ReturnsAsync(VerifyResult(true)); - var result = await svc.VerifyCredentialsAsync(); + var result = await VerifyService(mailer).VerifyCredentialsAsync(); Assert.True(result.Ok); Assert.Equal("acc-123", result.Account); - Assert.NotNull(handler.LastRequest); - Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method); - Assert.Equal("https://mailer.test/mailer/fc?fn=verify", handler.LastRequest.RequestUri!.ToString()); - Assert.Equal("Basic", handler.LastRequest.Headers.Authorization!.Scheme); + mailer.Verify(m => m.VerifyAsync(null, It.IsAny()), Times.Once); } [Fact] public async Task VerifyCredentialsAsync_ServerIdConfigured_IsSentAndEchoedOnSuccess() { - var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\",\"serverid\":\"srv-1\"}"); - var svc = new ProcessWebComService( - NullLogger.Instance, intranet: null!, - Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-1" }), - Options.Create(new FuchsEmailSettings()), - new StubHttpClientFactory(handler)); + var mailer = new Mock(); + mailer.Setup(m => m.VerifyAsync("srv-1", It.IsAny())).ReturnsAsync(VerifyResult(true, serverId: "srv-1")); - var result = await svc.VerifyCredentialsAsync(); + var result = await VerifyService(mailer, serverId: "srv-1").VerifyCredentialsAsync(); Assert.True(result.Ok); Assert.Equal("srv-1", result.ServerId); - Assert.Equal("https://mailer.test/mailer/fc?fn=verify&serverid=srv-1", handler.LastRequest!.RequestUri!.ToString()); } [Fact] public async Task VerifyCredentialsAsync_ServerIdNotOwnedByAccount_ReturnsNotOkWithServerIdDetail() { - var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false,\"account\":\"acc-123\",\"serverid\":\"srv-wrong\"}"); - var svc = new ProcessWebComService( - NullLogger.Instance, intranet: null!, - Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-wrong" }), - Options.Create(new FuchsEmailSettings()), - new StubHttpClientFactory(handler)); + var mailer = new Mock(); + mailer.Setup(m => m.VerifyAsync("srv-wrong", It.IsAny())).ReturnsAsync(VerifyResult(false, serverId: "srv-wrong")); - var result = await svc.VerifyCredentialsAsync(); + var result = await VerifyService(mailer, serverId: "srv-wrong").VerifyCredentialsAsync(); Assert.False(result.Ok); Assert.Contains("srv-wrong", result.Detail); @@ -634,44 +697,37 @@ public class ProcessWebComServiceTests [Fact] public async Task VerifyCredentialsAsync_MailerReturnsOkFalse_ReturnsNotOk() { - var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false}"); - var svc = new ProcessWebComService( - NullLogger.Instance, intranet: null!, - Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }), - Options.Create(new FuchsEmailSettings()), - new StubHttpClientFactory(handler)); + var mailer = new Mock(); + mailer.Setup(m => m.VerifyAsync(null, It.IsAny())).ReturnsAsync(VerifyResult(false)); - var result = await svc.VerifyCredentialsAsync(); + var result = await VerifyService(mailer).VerifyCredentialsAsync(); Assert.False(result.Ok); + Assert.Contains("ungültige Zugangsdaten", result.Detail); } [Fact] public async Task VerifyCredentialsAsync_Http401_ReturnsNotOkWithDetail() { - var handler = new CapturingRequestHandler(HttpStatusCode.Unauthorized, "unauthorized"); - var svc = new ProcessWebComService( - NullLogger.Instance, intranet: null!, - Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "wrong" }), - Options.Create(new FuchsEmailSettings()), - new StubHttpClientFactory(handler)); + var mailer = new Mock(); + mailer.Setup(m => m.VerifyAsync(null, It.IsAny())) + .ReturnsAsync(new PWMailerResult { StatusCode = 401, Error = "not authenticated", Attempts = 1 }); - var result = await svc.VerifyCredentialsAsync(); + var result = await VerifyService(mailer, token: "wrong").VerifyCredentialsAsync(); Assert.False(result.Ok); Assert.Contains("401", result.Detail); + Assert.Contains("not authenticated", result.Detail); } [Fact] - public async Task VerifyCredentialsAsync_TransportException_ReturnsNotOkWithExceptionDetail() + public async Task VerifyCredentialsAsync_TransportError_ReturnsNotOkWithClientError() { - var svc = new ProcessWebComService( - NullLogger.Instance, intranet: null!, - Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }), - Options.Create(new FuchsEmailSettings()), - new StubHttpClientFactory(new ThrowingHandler())); + var mailer = new Mock(); + mailer.Setup(m => m.VerifyAsync(null, It.IsAny())) + .ReturnsAsync(new PWMailerResult { StatusCode = 0, Error = "simulated transport failure", Attempts = 3, TimedOut = false }); - var result = await svc.VerifyCredentialsAsync(); + var result = await VerifyService(mailer).VerifyCredentialsAsync(); Assert.False(result.Ok); Assert.Contains("simulated transport failure", result.Detail); @@ -680,19 +736,14 @@ public class ProcessWebComServiceTests [Theory] [InlineData("", "tok")] [InlineData("acct", "")] - public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingApi(string accountId, string token) + public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingClient(string accountId, string token) { - var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true}"); - var svc = new ProcessWebComService( - NullLogger.Instance, intranet: null!, - Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token }), - Options.Create(new FuchsEmailSettings()), - new StubHttpClientFactory(handler)); + var mailer = new Mock(); - var result = await svc.VerifyCredentialsAsync(); + var result = await VerifyService(mailer, accountId, token).VerifyCredentialsAsync(); Assert.False(result.Ok); - Assert.Null(handler.LastRequest); + mailer.Verify(m => m.VerifyAsync(It.IsAny(), It.IsAny()), Times.Never); } // ── Metric emission (performance indicator) ──────────────────────────────── @@ -711,11 +762,9 @@ public class ProcessWebComServiceTests listener.SetMeasurementEventCallback((_, value, _, _) => Interlocked.Add(ref delta, value)); listener.Start(); - var svc = CreateService(new StubHandler(HttpStatusCode.OK)); + var svc = CreateService(new MailerStub()); await svc.SendEmailAsync("inv_metric", "S", "

x

", "kunde@example.de", "Kunde"); Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send."); } - - private const string FuchsMeterName = "Fuchs.Intranet"; } diff --git a/Fuchs.Tests/SystemStatusServiceTests.cs b/Fuchs.Tests/SystemStatusServiceTests.cs index 628d240..e2ce515 100644 --- a/Fuchs.Tests/SystemStatusServiceTests.cs +++ b/Fuchs.Tests/SystemStatusServiceTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.Metrics; using System.Threading; using System.Threading.Tasks; +using Fuchs.intranet; using Fuchs.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -24,6 +25,7 @@ namespace Fuchs.Tests; public class SystemStatusServiceTests { private const string FuchsMeterName = "Fuchs.Intranet"; + private static readonly byte[] FakePdfBytes = { 0x25, 0x50, 0x44, 0x46, 0x2D }; // "%PDF-" private static IConfiguration Config(Dictionary? overrides = null) { @@ -51,6 +53,7 @@ public class SystemStatusServiceTests Mock? blob = null, Mock? mfr = null, Mock? com = null, + Mock? pdf = null, ProcessWebComSettings? mailer = null, FuchsEmailSettings? email = null, AzureBlobStorageSettings? blobSettings = null, @@ -67,6 +70,16 @@ public class SystemStatusServiceTests .ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc" }); } + // Default PDF stub: renders any letter to a fixed, recognisable byte payload. + var pdfMock = pdf ?? new Mock(); + if (pdf is null) + { + pdfMock.Setup(p => p.WriteLetterAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MigraDoc.DocumentObjectModel.Document()); + pdfMock.Setup(p => p.DocToPdfBytes(It.IsAny())) + .Returns(FakePdfBytes); + } + return new SystemStatusService( config ?? Config(), env.Object, @@ -74,6 +87,7 @@ public class SystemStatusServiceTests (blob ?? new Mock()).Object, (mfr ?? new Mock()).Object, comMock.Object, + pdfMock.Object, Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }), Options.Create(email ?? new FuchsEmailSettings()), Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }), @@ -495,6 +509,140 @@ public class SystemStatusServiceTests Assert.Null(r.OverrideRecipient); } + [Fact] + public async Task SendTestEmailAsync_AttachPdfDefault_AttachesRenderedPdfUnderFixedName() + { + Dictionary? capturedAttachments = null; + FuchsPdf.FdsTextBlocks? capturedBlocks = null; + var com = new Mock(); + com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, string?>( + (_, _, _, _, _, att, _) => capturedAttachments = att) + .ReturnsAsync(true); + var pdf = new Mock(); + pdf.Setup(p => p.WriteLetterAsync(It.IsAny(), false)) + .Callback((tb, _) => capturedBlocks = tb) + .ReturnsAsync(new MigraDoc.DocumentObjectModel.Document()); + pdf.Setup(p => p.DocToPdfBytes(It.IsAny())).Returns(FakePdfBytes); + var svc = Build(com: com, pdf: pdf); + + var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1\r\nZeile2"); + + Assert.True(r.Sent); + Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName); + Assert.Contains(SystemStatusService.TestPdfAttachmentName, r.Message); + Assert.NotNull(capturedAttachments); + var (name, bytes) = Assert.Single(capturedAttachments!); + Assert.Equal(SystemStatusService.TestPdfAttachmentName, name); + Assert.Equal(FakePdfBytes, bytes); + // The letter carries the mail's own subject/body and addresses the requested recipient. + Assert.NotNull(capturedBlocks); + Assert.Equal("Betreff", capturedBlocks!.Subject); + Assert.Equal("Zeile1\nZeile2", capturedBlocks.Body); + Assert.Contains("dest@example.test", capturedBlocks.Address); + pdf.Verify(p => p.DocToPdfBytes(It.IsAny()), Times.Once); + } + + [Fact] + public async Task SendTestEmailAsync_AttachPdfFalse_SendsWithoutAttachmentAndSkipsRendering() + { + Dictionary? capturedAttachments = new(); + var com = new Mock(); + com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, string?>( + (_, _, _, _, _, att, _) => capturedAttachments = att) + .ReturnsAsync(true); + var pdf = new Mock(MockBehavior.Strict); // any render call would throw + var svc = Build(com: com, pdf: pdf); + + var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body", attachPdf: false); + + Assert.True(r.Sent); + Assert.Null(r.AttachmentName); + Assert.Null(capturedAttachments); + Assert.DoesNotContain("PDF", r.Message); + } + + [Fact] + public async Task SendTestEmailAsync_PdfRenderingFails_DoesNotSendAndReportsError() + { + var com = new Mock(); + var pdf = new Mock(); + pdf.Setup(p => p.WriteLetterAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("font resolver missing")); + var svc = Build(com: com, pdf: pdf); + + var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body"); + + Assert.False(r.Sent); + Assert.Null(r.AttachmentName); + Assert.Contains("PDF-Anhang", r.Message); + Assert.Contains("font resolver missing", r.Message); + Assert.Equal("dest@example.test", r.RequestedRecipient); + com.Verify(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task SendTestEmailAsync_MailerRejectsWithAttachment_ReportsAttachmentNotConfirmed() + { + var com = new Mock(); + com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .ReturnsAsync(false); // ADR 0028: also the outcome when attachmentsProcessed ≠ sent + var svc = Build(com: com); + + var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body"); + + Assert.False(r.Sent); + Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName); + Assert.Contains("Anhang", r.Message); + } + + [Fact] + public async Task SendTestEmailAsync_RealLetterRenderer_ProducesValidOnePagePdfAttachment() + { + // Real MigraDoc/PdfSharp rendering of the letter the service builds (raw, without the + // Spire PDF/A pass, so the test does not depend on a Spire license) — proves the text + // blocks the service assembles are renderable, not just that a stub was called. + Dictionary? capturedAttachments = null; + var com = new Mock(); + com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny?>(), It.IsAny())) + .Callback?, string?>( + (_, _, _, _, _, att, _) => capturedAttachments = att) + .ReturnsAsync(true); + var pdf = new Mock(); + pdf.Setup(p => p.WriteLetterAsync(It.IsAny(), It.IsAny())) + .Returns((tb, draft) => FuchsPdf.WriteLetter(tb, draft, FuchsPdf.DeCulture)); + pdf.Setup(p => p.DocToPdfBytes(It.IsAny())) + .Returns(FuchsPdf.DocToPdfBytesRaw); + var svc = Build(com: com, pdf: pdf); + + var r = await svc.SendTestEmailAsync("dest@example.test", "Fuchs Intranet – Test-E-Mail", "Dies ist eine Test-E-Mail.\nZweite Zeile."); + + Assert.True(r.Sent); + byte[] bytes = Assert.Single(capturedAttachments!).Value; + Assert.True(bytes.Length > 1000, "attachment should be a real rendered PDF"); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4)); + using var opened = PdfSharp.Pdf.IO.PdfReader.Open(new System.IO.MemoryStream(bytes), PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import); + Assert.Equal(1, opened.PageCount); + } + + [Fact] + public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotRenderPdf() + { + var pdf = new Mock(MockBehavior.Strict); + var svc = Build(pdf: pdf); + + var r = await svc.SendTestEmailAsync("", "Betreff", "Body"); + + Assert.False(r.Sent); + Assert.Null(r.AttachmentName); + } + [Fact] public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient() { diff --git a/Fuchs/Controllers/IntranetController.Admin.cs b/Fuchs/Controllers/IntranetController.Admin.cs index 0865229..cd544b4 100644 --- a/Fuchs/Controllers/IntranetController.Admin.cs +++ b/Fuchs/Controllers/IntranetController.Admin.cs @@ -69,13 +69,34 @@ public partial class IntranetController { if (!HasForm("to", "subject")) return BadRequest400(); + // attachpdf is the dialog's Ja/Nein select ("true"/"false"); absent → attach (default). + bool attachPdf = !bool.TryParse(Form("attachpdf"), out bool parsed) || parsed; var result = await status.SendTestEmailAsync( - Form("to"), Form("subject"), Form("body"), HttpContext.RequestAborted); - _logger.LogInformation("Admin test email requested by user={User} to={To} sent={Sent}", - UserAccountID, result.RequestedRecipient, result.Sent); + Form("to"), Form("subject"), Form("body"), attachPdf, HttpContext.RequestAborted); + _logger.LogInformation("Admin test email requested by user={User} to={To} attachment={Attachment} sent={Sent}", + UserAccountID, result.RequestedRecipient, result.AttachmentName, result.Sent); return AdminJson(new { result }); } + // Maintenance action, reachable only from the admin-gated "Specialties" page + // (IntranetController.Specialties.cs / fis.specialties.js): backs up an invoice's + // stored PDF and clears it so the document regenerates on next read (ADR 0025 / + // Docs/Decisions/0027). GET-safe crawlers/prefetchers must never trigger this, so it + // requires POST despite the rest of the Admin module tolerating either verb. + case "backupclearinvoicefile": + { + if (!HttpContext.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)) + return BadRequest400(); + string invoiceId = string.IsNullOrWhiteSpace(code) ? Form("id") : code; + if (string.IsNullOrWhiteSpace(invoiceId)) + return BadRequest400(); + var backup = await _invoiceFileBackup.BackupAndClearAsync(invoiceId, UserAccountID, DbSec, HttpContext.RequestAborted); + _logger.LogWarning( + "Admin invoice-file backup/clear requested by user={User} invoiceId={InvoiceId} success={Success} reason={Reason}", + UserAccountID, invoiceId, backup.Success, backup.Reason); + return AdminJson(new { backup }); + } + default: _logger.LogWarning("Admin: no handler for action={Action}, user={User}", id, UserAccountID); return BadRequest400(); diff --git a/Fuchs/Controllers/IntranetController.Reminder.cs b/Fuchs/Controllers/IntranetController.Reminder.cs index 6225b91..921a7da 100644 --- a/Fuchs/Controllers/IntranetController.Reminder.cs +++ b/Fuchs/Controllers/IntranetController.Reminder.cs @@ -155,8 +155,11 @@ public partial class IntranetController { var remdoc = new Dictionary { [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte }; + // The invoice copy is optional: an empty varbinary (invoice file never stored) must + // not attach a 0-byte file — the mailer refuses empty attachments and the whole + // reminder would fail instead of going out without the copy. if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && - frdic.no("InvoiceFile", null!) is byte[] invFile) + frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile) remdoc[frdic.nz("InvoiceFileName")] = invFile; _logger.LogInformation("Reminder conf: emailing finalized reminder {RemId} to {Email} user={User}", @@ -247,8 +250,11 @@ public partial class IntranetController { var remdoc = new Dictionary { [frdic.nz("DocumentName", "").ne("Zahlungserinnerung.pdf")] = filebyte }; + // The invoice copy is optional: an empty varbinary (invoice file never stored) must + // not attach a 0-byte file — the mailer refuses empty attachments and the whole + // reminder would fail instead of going out without the copy. if (!string.IsNullOrEmpty(frdic.nz("InvoiceFileName")) && - frdic.no("InvoiceFile", null!) is byte[] invFile) + frdic.no("InvoiceFile", null!) is byte[] { Length: > 0 } invFile) remdoc[frdic.nz("InvoiceFileName")] = invFile; bool sent = await _comService.SendEmailAsync($"rem_{remId}", $"SanitärFuchs - {frdic.nz("subject").ne(frdic.nz("DocumentName"))}", diff --git a/Fuchs/Controllers/IntranetController.Specialties.cs b/Fuchs/Controllers/IntranetController.Specialties.cs new file mode 100644 index 0000000..9dc970d --- /dev/null +++ b/Fuchs/Controllers/IntranetController.Specialties.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using static OCORE.web.mvc_helper_async; + +namespace Fuchs.Controllers; + +// Partial class: "Specialties" module — the admin page (URL /specialties, menu entry under +// Administration) that surfaces the otherwise hidden maintenance actions as small card forms. +// +// This partial only answers the module's auth probe; the cards call the real action endpoints +// directly (e.g. POST /do/admin/backupclearinvoicefile/ in IntranetController.Admin.cs), +// each of which enforces the same fds_sys > 4 gate server-side. See +// Docs/Concepts/admin-specialties.md. +public partial class IntranetController +{ + private async Task Do_Process_Specialties(string fn, string id, string code) + { + _logger.LogDebug("Do_Process_Specialties action={Action} user={User}", id, UserAccountID); + + int authLevel = await GetSystemAdminAuthAsync(fn, id, code); + bool authorized = authLevel > AdminMinAuthExclusive; + + if (id.Equals("auth", StringComparison.OrdinalIgnoreCase)) + return await JSONAsync(new { manage = authorized ? 1 : 0, level = authLevel }); + + if (!authorized) + { + _logger.LogWarning("Specialties access denied for user={User} (fds_sys={Level}) action={Action}", + UserAccountID, authLevel, id); + return Unauthorized401(); + } + + _logger.LogWarning("Specialties: no handler for action={Action}, user={User}", id, UserAccountID); + return BadRequest400(); + } +} diff --git a/Fuchs/Controllers/IntranetController.cs b/Fuchs/Controllers/IntranetController.cs index 7a7327f..6becf0b 100644 --- a/Fuchs/Controllers/IntranetController.cs +++ b/Fuchs/Controllers/IntranetController.cs @@ -33,6 +33,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller private readonly IWidgetService _widgets; private readonly IReportService _reports; private readonly IInvoiceService _invoices; + private readonly IInvoiceFileBackupService _invoiceFileBackup; private readonly IReminderService _reminders; private readonly IEventService _events; private readonly IInvoiceDraftService _invoiceDrafts; @@ -65,6 +66,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller IWidgetService widgets, IReportService reports, IInvoiceService invoices, + IInvoiceFileBackupService invoiceFileBackup, IReminderService reminders, IEventService events, IInvoiceDraftService invoiceDrafts, @@ -82,6 +84,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller _widgets = widgets; _reports = reports; _invoices = invoices; + _invoiceFileBackup = invoiceFileBackup; _reminders = reminders; _events = events; _invoiceDrafts = invoiceDrafts; @@ -185,6 +188,7 @@ public partial class IntranetController : Microsoft.AspNetCore.Mvc.Controller "rep" => await Do_Process_Reports(fn, id, code), "bam" => await Do_Process_Bankings(fn, id, code), "admin" => await Do_Process_Admin(fn, id, code), + "specialties" => await Do_Process_Specialties(fn, id, code), "usr" => await Do_Process_AdminUsers(fn, id, code), "usrgrp" => await Do_Process_AdminGroups(fn, id, code), "auth" => await HandleAuth(fn, id, code), diff --git a/Fuchs/Docs/ARCHITECTURE.md b/Fuchs/Docs/ARCHITECTURE.md index d070a35..f4c8b0c 100644 --- a/Fuchs/Docs/ARCHITECTURE.md +++ b/Fuchs/Docs/ARCHITECTURE.md @@ -196,8 +196,8 @@ Cookie-based authentication (`CookieAuthenticationDefaults`) with custom claims > **Status: DONE.** The services below are implemented and DI-registered in > `Program.cs`. The original extraction rationale is retained for reference / -> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, inline -> base64 attachments), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` → +> history. `FuchsFdsEmail` → `IComService` (ProcessWeb Mailer API, multipart +> attachments — see `Docs/Concepts/processweb-mailer-interface.md`), `FuchsWidgets` → `IWidgetService`, `FuchsPdf` → > `IPdfService`, `Banking` → `IBankingService` (now MT940 **and** CAMT), > `FdsInvoiceData`/`FdsReminderData` → `IInvoiceService`/`IReminderService` > (data classes are now pure POCOs), `FuchsReports` → `IReportService` diff --git a/Fuchs/Docs/Concepts/admin-specialties.md b/Fuchs/Docs/Concepts/admin-specialties.md new file mode 100644 index 0000000..b9a25eb --- /dev/null +++ b/Fuchs/Docs/Concepts/admin-specialties.md @@ -0,0 +1,73 @@ +--- +status: Active +lastUpdated: 2026-09-17 +applyTo: + - "Fuchs/Controllers/IntranetController.Specialties.cs" + - "Fuchs/js/intranet/modules/fis.specialties*" + - "Fuchs/js/intranet/fis_main_menu.js" + - "Fuchs/js/intranet/fis_main_go.js" +relatedDecisions: + - "0027-admin-invoice-file-backup-and-clear.md" +--- + +# Admin "Specialties" page (`/specialties`) + +## Summary + +An admin-only page that surfaces the otherwise hidden maintenance actions as +cards, each a small form. It exists so support does not need SSMS or a +hand-crafted POST for actions like resetting an invoice's stored PDF. It is +a regular lazily loaded frontend module (`fn = specialties`), gated like the +rest of the Administration menu (`fds_sys > 4`), and reachable both from the +menu (Administration → Spezialfunktionen) and by opening the URL +`/specialties` directly. + +## How it works + +- **Module load** follows the standard contract (see + `admin-system-status.md`): the menu item `init:specialties` → + `$ocms.init('specialties')` → `POST /do/specialties/auth` → when + `manage > 0`, import `web/fis.specialties.de.js` + `fis.specialties.css` + and call `init2`. `IntranetController.Specialties.cs` answers only that + auth probe (same `GetSystemAdminAuthAsync` gate as the Admin module) and + 401s everything else. +- **Deep link.** `IntranetController.Index` renders the same SPA shell for + every `/{fn}` URL and ignores `fn`; there is no client router. `fis_main_go.js` + therefore calls `$fis.openModuleFromPath()` once the admin menu has settled: + it takes the first path segment and calls `$ocms.init(fn)` **only if the + user's menu offers an `init:` item** (admin dropdown included). A + non-admin opening `/specialties` sees the dashboard, as before; arbitrary + paths never trigger a module load. +- **Cards.** `fis.specialties.js` keeps an array `cards` of builder + functions. Each card is `title + description +
+ result box`; + `$spc.submit(c, { button, confirm, run, report })` wires HTML5 validation, + a `confirm()`, the `postXT` call (`run(values)` returns the request) and + the inline result (`report(response)` → `{ ok, lines }`). The card calls + the action's **real** endpoint — every such endpoint enforces the admin + gate itself; the page adds no authority. +- **Current cards** + - *Rechnungsdatei zurücksetzen (Soft-Delete)* → `POST + /do/admin/backupclearinvoicefile/` (ADR 0027). Shows the + outcome and the blob backup URI, or the German text for the service's + `reason` (`invoice_not_found`, `not_final`, `external`, `no_file`, + `db_error`). An invoice without a stored file (`hasFile` from + `fds__getInvoice`) is answered `no_file` before any blob copy, so + repeated clicks leave no spare `backup_*` blobs. +- **Adding a card:** append a builder to `cards`, add its texts to + `fis.specialties_txt_de.js`, run `npx gulp min`. No server change is + needed unless the action itself is new. +- **Build:** bundle entries in `Fuchs/bdlconfig.json` + (`intranet:specialties`); generated files under `wwwroot/web/`. + +## Key files + +- `Fuchs/Controllers/IntranetController.Specialties.cs` — auth probe. +- `Fuchs/Controllers/IntranetController.cs` — `"specialties"` case in `Do`. +- `Fuchs/js/intranet/modules/fis.specialties.js` / `_txt_de.js` / `.scss`. +- `Fuchs/js/intranet/fis_main_menu.js` — menu item + `openModuleFromPath`. +- `Fuchs/js/intranet/fis_main_go.js` — deep link after menu settles. +- `Fuchs/js/intranet/fis_texts_gui_de.js` — `m_specialties` label. + +## Related decisions + +- [0027 — Admin invoice-file backup-and-clear action](../Decisions/0027-admin-invoice-file-backup-and-clear.md) diff --git a/Fuchs/Docs/Concepts/admin-system-status.md b/Fuchs/Docs/Concepts/admin-system-status.md index 11adfe3..e3a840e 100644 --- a/Fuchs/Docs/Concepts/admin-system-status.md +++ b/Fuchs/Docs/Concepts/admin-system-status.md @@ -47,7 +47,7 @@ click "Administration" → $ocms.init('admin') $ocms.admin.init2() → init3() renders the page POST /do/admin/status → { info, probes } full snapshot + all probes per-card "Aktualisieren" → POST /do/admin/probe/ → { probe } - "Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body) → { result } + "Test-E-Mail senden" → POST /do/admin/testmail (to/subject/body/attachpdf) → { result } ``` ### Backend (`ISystemStatusService` / `SystemStatusService`, scoped) @@ -60,7 +60,7 @@ click "Administration" → $ocms.init('admin') (`status` = `ok`/`error`/`disabled`/`unconfigured`, `ok`, `message`, `detail`, `durationMs`, optional `metrics`). They never throw — failures are captured in the result. `database` runs `SELECT @@SERVERNAME, DB_NAME(), SUSER_SNAME()`; `email` calls - `IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/mailer/fc?fn=verify` with the configured + `IComService.VerifyCredentialsAsync` (GET `{BaseUrl}/api/mailer/verify` with the configured Basic-auth account/token — see the Mailer API contract) and only turns `ok` when the Mailer API itself confirms the credentials (`ok:true`); when `ProcessWebComSettings.ServerId` is configured it is sent along as `&serverid=...` so the API also confirms this server belongs to the account @@ -74,9 +74,18 @@ click "Administration" → $ocms.init('admin') count** for the invoice/reminder containers, surfaced as `metrics` and totalled in the message); `mfr` calls `IMfrClientFactory.Create().GetEntities()`. The generic `metrics` (label/value pairs) is how a probe reports extra facts for display — the blob probe uses it for the file counts. -- `SendTestEmailAsync(to, subject, body)` HTML-encodes the body and sends via `IComService`, so - the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other mail; the result - reports the requested recipient and, when active, the override target. +- `SendTestEmailAsync(to, subject, body, attachPdf)` HTML-encodes the body and sends via + `IComService`, so the `Fuchs:Email:OverrideRecipient` redirect applies exactly as for any other + mail; the result reports the requested recipient and, when active, the override target. + With `attachPdf` (dialog select "PDF-Anhang mitsenden", default *Ja*; absent form value ⇒ on) + a one-page letter PDF (`Fuchs-Intranet-Testmail.pdf`) is rendered in memory via + `IPdfService.WriteLetterAsync` + `DocToPdfBytes` — standard Fuchs letterhead, the recipient in + the address window, `Datum`/`Umgebung`/`Nummer=admin_test` in the admin block, the mail's own + subject and body as the letter text; no DB or stored document is touched. This exercises the + Mailer's multipart/attachment path, so per ADR 0028 `sent` is only `true` when the Mailer also + confirmed the attachment (`attachmentsProcessed == 1`); the result's `attachmentName` says + whether an attachment was part of the send. A PDF rendering failure is reported in the result + (`sent=false`, message names the error) and nothing is sent. - Every probe emits the `fuchs.systemstatus.probes` counter tagged by component + status and an `systemstatus.probe` activity span. diff --git a/Fuchs/Docs/Concepts/processweb-mailer-interface.md b/Fuchs/Docs/Concepts/processweb-mailer-interface.md new file mode 100644 index 0000000..aaaaf9c --- /dev/null +++ b/Fuchs/Docs/Concepts/processweb-mailer-interface.md @@ -0,0 +1,130 @@ +--- +status: Active +lastUpdated: 2026-09-17 +applyTo: + - "Fuchs/Services/IComService.cs" + - "Fuchs/Services/ProcessWebComService.cs" + - "Fuchs/Services/ProcessWebComSettings.cs" + - "Fuchs/Services/FuchsMailerClient.cs" + - "Fuchs/Services/MailerAttachmentPolicy.cs" + - "Fuchs.Tests/ProcessWebComServiceTests.cs" + - "Fuchs.Tests/FuchsMailerClientTests.cs" +relatedDecisions: + - "0028-mailer-push-com-multipart-and-attachment-confirmation.md" + - "0029-outbound-mail-via-ocore-pwmailer-client.md" +--- + +# ProcessWeb Mailer interface (outbound email/SMS) + +## Summary + +All outbound email and SMS leaves Fuchs through `IComService` → +`ProcessWebComService`, which is a thin adapter over the OCORE_web +**`IPWMailerClientService`** (`OCORE_web/comm/pwmailer/`). The client owns +the HTTP contract of the ProcessWeb Mailer API; Fuchs owns what is specific +to Fuchs — the recipient-override safety net, the signature, the attachment +limits, the `fds__emaillog` audit row and telemetry. + +The authoritative, server-side contract is the Mailer's own function catalog: + +> **https://processweb.de/api/mailer/fc** — subject to change as features are +> added, but kept backward compatible. The OCORE_web client is kept in sync +> with it (its header says so and names the server-side files); read both +> before changing anything here. + +## How it works + +### Layering + +| Layer | File | Responsibility | +|-------|------|----------------| +| Adapter | `Fuchs/Services/ProcessWebComService.cs` | `IComService`: validate recipient, apply `Fuchs:Email:OverrideRecipient`, append signature, build a `PWMailerMessage`, interpret the `PWMailerResult`, write `fds__emaillog`, emit metrics | +| Fuchs client | `Fuchs/Services/FuchsMailerClient.cs` | `PWMailerClientService` subclass; overrides `ValidateMessage` to add the attachment limits (`MailerAttachmentPolicy`) on top of the contract's base rules | +| OCORE client | `OCORE_web/comm/pwmailer/PWMailerClientService*.cs` | endpoints, envelope, JSON vs multipart (unnamed envelope field + one file part per attachment), `attachmentCount`, Basic auth, token resolution, `x-pcw-excd` error headers, retries/timeouts, typed results | + +### Configuration + +`Fuchs:Mailer` (`ProcessWebComSettings`) stays the single config section. In +`Program.cs` it is mapped onto `PWMailerClientOptions`: + +- `BaseUrl` (`https://processweb.de`) + `/api/mailer` → `PWMailerClientOptions.BaseUrl` +- `AccountId` → `Account`, `ServerId` → `ServerId`, `Token` → `Token` +- `TokenSecretName = "Fuchs--Mailer--Token"` — the same Key Vault secret + OCORE secret management already maps into `Fuchs:Mailer:Token`. In + production the configuration value is therefore already the plain token; + the name only matters when the `MANAGED_BY_KEYVAULT` placeholder is still + in place (e.g. locally), where the client loads it on demand. +- `Attachments` (`MaxFileBytes` 10 MB, `MaxMessageBytes` 20 MB, `MaxCount` + 10 — the contract defaults) feed `FuchsMailerClient`'s pre-validation. + +Registration: `AddPWMailerClient()` (singleton, named +`HttpClient` `PWMailerClient`). `ProcessWebComService` stays scoped. + +### Sending an email + +1. `SendEmailAsync` validates the address, applies the override, appends the + signature, and builds `PWMailerMessage { To, Cc, Subject, Body, Reference, + ComType = "e", Attachments }`. `AttachmentCount` is left `null`, so the + client sends the real count and the Mailer cross-checks it (400 on + mismatch). +2. `FuchsMailerClient.ValidateMessage` runs *inside* `PushAsync` before any + bytes go out: base rules (server id, recipient, subject, body, empty file, + missing extension) plus Fuchs limits (count, per-file size, total size, + forbidden extensions — `MailerAttachmentPolicy.ForbiddenExtensions` is the + contract's quoted subset; the server's list is longer and authoritative). + A violation returns a result with `StatusCode 0`, `Attempts 0` and the + `"; "`-joined rule messages as `Error`. +3. The client posts JSON (no attachments) or multipart (with) to + `POST /api/mailer/push_com`, never retrying a push. +4. The adapter interprets the `PWMailerResult`: + +| Result | `SendEmailAsync` | audit `fds__emaillog` | `fuchs.emails.failed{reason}` | +|--------|------------------|-----------------------|-------------------------------| +| `Success` and (no attachments or `AttachmentsProcessed == sent`) | `true` | `guid = comStackID` | — (`fuchs.emails.sent`) | +| `Success` but `AttachmentsProcessed != sent` | `false` | counts in log, `guid = comStackID` | `attachments-mismatch` | +| `IsConflict` (409: identical message still pending at the Mailer — it will be sent, nothing new queued) | `true` | "Duplicate of a still-pending entry (409) …" | — (`fuchs.emails.sent`) | +| `StatusCode 0`, `Attempts 0`, `Exception is ArgumentException` (refused by pre-validation) | `false` | one line per rule | `validation` | +| `StatusCode 0`, `Attempts 0`, no exception (options unusable / token not resolvable, e.g. Key Vault outage) | `false` | `Mailer client not usable: …` (Error log) | `client-config` | +| any other failure | `false` | `API error {status}: {error}` | `api-error` | +| client threw | `false` | exception detail | `api-error` | + +`reference` (`inv_` etc.) is the audit key and part of the Mailer's +idempotency key (server + reference + content, while the first entry is still +pending). A resend of the identical mail within that window answers 409 and +is reported as **success** — the first entry is delivered; nothing is sent +twice. A resend after delivery started is a new entry. Callers keep reusing +`inv_` (e.g. `req/resend`); no per-attempt reference is needed. + +### SMS and verification + +- `SendSmsAsync` pushes `PWMailerMessage { ComType = "s", To = mobile, Body }` + — the base validation requires no subject for `comType "s"`. +- `VerifyCredentialsAsync` → `IPWMailerClientService.VerifyAsync(serverId)` + (`GET /api/mailer/verify[?serverid=…]`), mapped to + `MailerCredentialsCheckResult` for the Admin system-status view. + +### Observability + +`fuchs.emails.sent` / `fuchs.emails.failed{reason}` with reasons +`invalid-email`, `disabled`, `validation`, `client-config`, +`attachments-mismatch`, `api-error`; `fuchs.email.send.duration{success}`. The OCORE +client additionally logs every failed call at Warning and exposes +`RequestSending`/`ResponseReceived`/`CallCompleted`/`CallFailed` events (not +subscribed by Fuchs today). + +## Key files + +- `Fuchs/Services/IComService.cs` — interface + `MailerCredentialsCheckResult`. +- `Fuchs/Services/ProcessWebComService.cs` — the adapter. +- `Fuchs/Services/FuchsMailerClient.cs` — `ValidateMessage` override. +- `Fuchs/Services/MailerAttachmentPolicy.cs` — limit rules (pure). +- `Fuchs/Services/ProcessWebComSettings.cs` — `Fuchs:Mailer` incl. `Attachments`. +- `Fuchs/Program.cs` — options mapping + `AddPWMailerClient()`. +- `OCORE_web/OCORE_web/comm/pwmailer/` — the client (submodule). +- `Fuchs.Tests/ProcessWebComServiceTests.cs`, `Fuchs.Tests/FuchsMailerClientTests.cs`; + the wire format is tested in `OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`. + +## Related decisions + +- [0028 — push_com multipart attachments and attachment confirmation](../Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md) +- [0029 — Outbound mail via the OCORE_web PWMailer client](../Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md) diff --git a/Fuchs/Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md b/Fuchs/Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md new file mode 100644 index 0000000..5ac284c --- /dev/null +++ b/Fuchs/Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md @@ -0,0 +1,114 @@ +--- +status: Accepted +date: 2026-09-17 +applyTo: + - "Fuchs/Controllers/IntranetController.Admin.cs" + - "Fuchs/Services/IInvoiceFileBackupService.cs" + - "Fuchs/Services/InvoiceFileBackupService.cs" + - "Fuchs/Services/IBlobStorageService.cs" + - "Fuchs/Services/AzureBlobStorageService.cs" + - "Fuchs_Database/dbo/Stored Procedures/fds__admin_backupAndClearInvoiceFile.sql" + - "Fuchs_Database/dbo/Tables/fds__invoice_file_backups.sql" +supersededBy: "" +--- + +# 0027 — Admin invoice-file backup-and-clear action + +## Context + +Support occasionally needs to force a finalised invoice's PDF to be regenerated +(e.g. it was rendered with wrong data before a fix, or is otherwise corrupt). +Since [0025](0025-invoice-document-regenerate-on-miss.md), clearing +`fds__invoices.[file]` on a finalised, non-external invoice is enough to make +the next `req/idoc`/`inv/rdoc` read regenerate and re-persist it. + +A manual maintenance proc for this already exists, +`[dbo].[_BackupAndClearInvoiceFile]`, but its own header comment states it is +"Manual-only maintenance utility. Never called from application code", and +0025's "Alternatives considered" explicitly rejected relying on it from the +app, partly because it leaves no application log entry. We still want this +capability reachable from the app (a hidden, admin-gated action) rather than +requiring direct SSMS access, without contradicting that standing decision. + +Additionally, `IBlobStorageService` archives invoice PDFs to Azure Blob +Storage under a fixed per-invoice blob name (`{invoiceId}/{fileName}`) and +always uploads with `overwrite: true`. If a cleared invoice's document is +later regenerated (0025), `StoreInvoiceDocumentFileAsync` re-uploads under +that same blob name — silently overwriting the very copy this action is +meant to preserve as a backup, unless it is moved first. + +## Decision + +- A **new** proc, `[dbo].[fds__admin_backupAndClearInvoiceFile]`, is added + rather than calling `_BackupAndClearInvoiceFile`. It copies the invoice's + current `[file]` bytes into a new table, `[dbo].[fds__invoice_file_backups]` + (one row per backup, not a timestamp-named ad-hoc table per call), then + clears `[file]` in the same transaction (`XACT_ABORT` + TRY/CATCH with + rollback and re-raise, so a clear can never commit without its backup row). + It logs through the normal + `fds__admin_activity` insert (matching `fds__setInvoiceFile`'s pattern) and + every call is also logged through the app's `ILogger`/DB-log-sink pipeline + (ADR 0024) — unlike the manual proc, this path is fully auditable. + `_BackupAndClearInvoiceFile` is left completely untouched as the manual + break-glass tool for cases the app can't reach (e.g. a corrupt row the + admin UI can't load). +- The action applies 0025's regeneration precondition **before** touching + anything: only a **finalised, non-external** invoice qualifies. Both the + service (`IsDraft`/`IsExternal`) and the proc (`[DateFinalized] IS NOT NULL + AND ISNULL([IsExternal],0) = 0`) enforce it. Clearing a draft's `[file]` + would let the next read persist a draft render through + `fds__setInvoiceFile` (`WHERE [file] IS NULL`), which then blocks the real + final PDF forever — the exact defect 0025 calls load-bearing; an external + (MFR) document has no template and would just become a permanent 404. +- `IBlobStorageService` gains `CopyInvoicePdfToBackupAsync`: a + **server-side copy** (`StartCopyFromUriAsync`, bytes never pass through + the app) of the existing blob to a timestamped backup name in the same + container — the original blob is **kept**, not renamed. A private source in + the same storage account is authorised by the request's own Shared Key + credential, so no SAS is generated. The copy runs *before* the DB clear, + so the archived original is safe before any regenerate-on-read (ADR 0025) + could overwrite the original name; because the original stays in place, a + DB error or no-op afterwards leaves the canonical archive exactly as it + was (with a harmless spare copy). Keeping the original also removes the + half-state a copy-then-delete would have ("copied but couldn't delete"). + Best-effort: a copy failure is logged and counted + (`fuchs.blobstorage.backupcopies.failed`), the DB backup row stays the + authoritative copy, and the DB step still runs. +- The action is exposed as a `Do_Process_Admin` case, + `admin/backupclearinvoicefile/`, gated by the existing + `fds_sys > 4` check and restricted to `POST` (a GET must never trigger it, + since GETs can be issued by crawlers/prefetchers without user intent). It + is not part of any regular workflow UI; its only frontend is a card on the + admin-only "Specialties" page (`/specialties`, see + `Docs/Concepts/admin-specialties.md`). + +## Consequences + +- Two invoice-file "backup and clear" code paths now exist for different + audiences: `_BackupAndClearInvoiceFile` (manual, SSMS, no app log) and + `fds__admin_backupAndClearInvoiceFile` (app-triggered, fully logged). Keep + them separate — do not merge them, and do not point the manual proc's + printed recovery links at the new admin action. +- `fds__invoice_file_backups` accumulates one row per backup with no + automatic retention/cleanup; if this ever becomes a storage concern, that + is a separate decision. +- Blob backup-copy is best-effort: if it fails (or storage is + disabled/unconfigured), the invoice's DB row is still backed up and + cleared — the archived blob is simply left as-is (and will be overwritten + by the next regeneration). The DB backup table is the only *guaranteed* + copy this action makes. +- The blob container accumulates one `backup__…` copy per action + next to the current blob; like `fds__invoice_file_backups`, no retention + is applied. + +## Alternatives considered + +- **Call `_BackupAndClearInvoiceFile` directly from the app.** Rejected: its + own header comment and ADR 0025 both state it must never be + application-triggered, and it produces no application log entry — using it + here would need updating that comment and (since decisions are immutable) + superseding 0025's "Alternatives considered" note, for no real benefit over + a small dedicated proc. +- **Skip the blob rename and let the next regeneration overwrite the archived + blob.** Rejected: the user asked for the blob copy to also survive as a + backup, and a silent overwrite on next read would defeat that. diff --git a/Fuchs/Docs/Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md b/Fuchs/Docs/Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md new file mode 100644 index 0000000..00f2294 --- /dev/null +++ b/Fuchs/Docs/Decisions/0028-mailer-push-com-multipart-and-attachment-confirmation.md @@ -0,0 +1,91 @@ +--- +status: Accepted +date: 2026-09-17 +applyTo: + - "Fuchs/Services/IComService.cs" + - "Fuchs/Services/ProcessWebComService.cs" + - "Fuchs/Services/ProcessWebComSettings.cs" + - "Fuchs/Services/MailerAttachmentPolicy.cs" +supersededBy: "" +--- + +# 0028 — Mailer push_com: multipart attachments, count confirmation, contract pre-validation + +## Context + +The ProcessWeb Mailer API (`push_com`) was extended to accept attachments. +Its contract — https://processweb.de/api/mailer/fc — specifies +`multipart/form-data` with the JSON envelope in an unnamed form field and one +file part per attachment, an optional `attachmentCount` safety check, and a +200 response reporting `attachmentsReceived`/`attachmentsProcessed`. + +`ProcessWebComService` predated that: it base64-inlined attachments as +`communication.attachments[]` in a JSON body. That property is not part of +the contract, so the Mailer accepted the message and dropped the files — a +200 for an invoice mail whose PDF never arrived, with no error anywhere. The +client also fabricated the audit `guid` (`Guid.NewGuid()`), treated a 409 +(idempotency duplicate) as a generic API error, and called the legacy +`/mailer/fc?fn=verify` and `api.processweb.de` paths. + +## Decision + +1. **Request shape follows the contract exactly.** No attachments → + `application/json` envelope. With attachments → `multipart/form-data`: + envelope in an unnamed field (`name=""`, set via an explicit + `Content-Disposition` because `MultipartFormDataContent.Add` rejects an + empty name), then one file part per attachment with real bytes. Field + names as in the contract (`comType`, not `comtype`). Base URL + `https://processweb.de`, paths `/api/mailer/push_com` and + `/api/mailer/verify`. +2. **`attachmentCount` is always sent** so the Mailer itself rejects a + request whose file parts don't match. +3. **A 200 is not success when attachments were sent unless + `attachmentsProcessed` equals the number sent.** Anything else — fewer + processed, or a body that isn't the contract JSON — is reported to the + caller as a failed send (`false`, reason `attachments-mismatch`, counts + in the audit log). Callers gate "mark as sent" on that boolean, so an + invoice whose PDF was not processed is never flagged as sent. +4. **Contract limits are pre-validated client-side** (`MailerAttachmentPolicy`: + count, per-file and per-message size, empty file, missing/forbidden + extension) with the contract's defaults configurable under + `Fuchs:Mailer:Attachments`. A violation fails before any bytes are sent, + with the exact rule in the audit log. Empty files are a violation, not + silently skipped as before. +5. **`comStackID` is the audit key**, falling back to the `Message-Id` + header; a local id only when the Mailer returned neither. +6. **409 is a distinct failure** (`duplicate`): the Mailer already holds a + message under this `reference` and did not queue this one. + +## Consequences + +- `IComService.SendEmailAsync` returning `true` now means "accepted *and* + every attachment confirmed processed" — strictly stronger than before. + Existing callers (`req/sconf`, reminders, admin test mail) need no change; + they already treat `false` as not-sent. +- A caller passing a 0-byte attachment now gets `false` instead of a mail + without the file. That is intended: an empty PDF is an upstream failure + that should surface, not be hidden by a successful-looking send. +- The forbidden-extension list in the client is the contract's quoted subset; + the server's longer list stays authoritative and still answers 400 for the + rest. +- The concept doc `Docs/Concepts/processweb-mailer-interface.md` describes + the current client behaviour and must be kept in sync when the contract + evolves (it is declared backward compatible, so additions rather than + breaks are expected). +- The verify response is still parsed as `{ ok, account, serverid }`; if the + `/api/mailer/verify` endpoint's body differs from the legacy `fc?fn=verify` + one, only `VerifyCredentialsAsync`'s DTO needs adjusting. + +## Alternatives considered + +- **Keep inline base64 and ask the Mailer to accept it too.** Rejected: the + contract is the Mailer's; two accepted shapes would just be a second + thing to keep compatible, and base64 inflates every PDF by a third. +- **Treat 200 as success regardless of `attachmentsProcessed`.** Rejected — + that is exactly the silent-drop failure mode this change exists to close. +- **Treat 409 as success ("already sent").** Rejected: a resend under the + same reference would then report success without anything being queued; + the caller must see it and decide (e.g. use a new reference). +- **Skip client-side validation and rely on the server's 400.** Rejected: + the 400 body is an opaque string in the audit log; a named rule is what + support needs, and it costs nothing. diff --git a/Fuchs/Docs/Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md b/Fuchs/Docs/Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md new file mode 100644 index 0000000..d4fc8f1 --- /dev/null +++ b/Fuchs/Docs/Decisions/0029-outbound-mail-via-ocore-pwmailer-client.md @@ -0,0 +1,101 @@ +--- +status: Accepted +date: 2026-09-17 +applyTo: + - "Fuchs/Services/ProcessWebComService.cs" + - "Fuchs/Services/FuchsMailerClient.cs" + - "Fuchs/Services/MailerAttachmentPolicy.cs" + - "Fuchs/Services/ProcessWebComSettings.cs" + - "Fuchs/Program.cs" +supersededBy: "" +--- + +# 0029 — Outbound mail goes through the OCORE_web PWMailer client + +## Context + +Right after [0028](0028-mailer-push-com-multipart-and-attachment-confirmation.md) +re-implemented the Mailer API's `push_com` contract by hand in +`ProcessWebComService`, OCORE_web gained a complete typed client for that +API (`OCORE_web/comm/pwmailer/`, `IPWMailerClientService`): every operation, +the JSON/multipart request shapes, `attachmentCount`, typed receipts, +Basic auth with Key Vault/encrypted token resolution, `x-pcw-excd` error +headers, retries and timeouts — declared to be kept in sync with the +server-side controllers. Keeping a second implementation of the same +contract in Fuchs would mean two things to update on every API change. + +The client validated a message's required fields and empty/extension-less +attachments, but had no hook for application rules (size limits, a +file-type deny-list) — `PWMailerMessage` is sealed and `PushAsync` called +`Validate` directly. + +## Decision + +1. **`ProcessWebComService` is an adapter over `IPWMailerClientService`.** + It keeps only what is Fuchs-specific: recipient validation and the + `Fuchs:Email:OverrideRecipient` safety net, the signature, building the + `PWMailerMessage`, interpreting the `PWMailerResult`, the `fds__emaillog` + audit row and telemetry. All HTTP transport lives in the OCORE client; + the hand-rolled `PostPushComAsync`/MIME guessing from 0028 is deleted. +2. **Pre-validation is an overridable hook in the client.** OCORE_web's + `PWMailerClientService` gains `protected virtual ValidateMessage(PWMailerMessage)` + (default: `PWMailerMessage.Validate`), called by `PushAsync` and + `PushBulkAsync` before anything is sent. Fuchs overrides it in + `FuchsMailerClient` to add the contract's attachment limits + (`MailerAttachmentPolicy`: count, per-file/per-message size, forbidden + extensions, configurable under `Fuchs:Mailer:Attachments`). A refusal is a + `PWMailerResult` with `StatusCode 0` / `Attempts 0`, which the adapter + reports as reason `validation` with one audit line per rule. +3. **One configuration section.** `Fuchs:Mailer` (`ProcessWebComSettings`) + remains the source; `Program.cs` maps it onto `PWMailerClientOptions` + (`BaseUrl + /api/mailer`, `AccountId → Account`, `Token`, `ServerId`) and + sets `TokenSecretName = "Fuchs--Mailer--Token"`, i.e. the Key Vault secret + OCORE secret management already maps into `Fuchs:Mailer:Token`. No second + `PWMailer` section, no second secret. +4. **0028's behavioural rules stand**, with one correction: multipart with + `attachmentCount`, a 200 counts as success only when + `attachmentsProcessed` equals the number sent, `comStackID` is the audit + key. **409 is now a success, not a `duplicate` failure** (this replaces + 0028's rule 6): the client documents 409 as "duplicate of a still-pending + entry with the same server/reference/content — nothing was enqueued, the + earlier entry will be sent". The mail is therefore on its way; the resend + paths (`req/resend`, reminders) reuse `inv_` as the reference, and a + double click must not surface as a send error. The deduplication is + recorded in the audit row. They are enforced partly by the client (shape, + count) and partly by the adapter (processed check, audit, 409 mapping). +5. Two small OCORE_web fixes made in the same change: `PWMailerMessage.Validate` + no longer demands a subject for `comType "s"` (SMS), and a call refused + by validation reports `Attempts = 0` as its documentation already stated. + +## Consequences + +- Fuchs no longer asserts the wire format in its own tests; that lives in + `OCORE_web/OCORE_webTests/comm/PWMailerClientServiceTests.cs`. Fuchs tests + assert the `PWMailerMessage` handed to the client and the interpretation of + each `PWMailerResult` shape, plus `FuchsMailerClient`'s refusals. +- OCORE_web is a submodule owned by another repository: the hook, the SMS + rule and the `Attempts` fix are a commit there and a pointer bump here. +- The client is a singleton with a cached token; after a token rotation + `IPWMailerClientService.ResetToken()` (on the concrete type) is the way to + pick it up without a restart — not wired to any admin action yet. +- Empty attachments are still refused (now by the client's base rule), so + an upstream render failure never produces a mail without its PDF. The + flip side: a caller attaching an *optional* file must skip an empty byte + array itself — the reminder's invoice copy (`IntranetController.Reminder.cs`) + does, otherwise a never-stored invoice file would fail the whole reminder + instead of sending it without the copy. +- The client's `getCom`/`getComStatus`/`abortCom` and inbox operations are + available to Fuchs for free (e.g. a delivery-status column keyed by the + audited `comStackID`) — not used yet. + +## Alternatives considered + +- **Keep the 0028 hand-rolled transport.** Rejected: duplicate contract + implementation, and the OCORE client is the one declared to track the + server. +- **Pre-validate in the adapter only (before `PushAsync`).** Rejected: the + rules belong with the client's own validation so every OCORE consumer can + apply them the same way, and the user asked for an overridable hook. +- **Bind a separate `PWMailer` config section.** Rejected: `Fuchs:Mailer` is + already Key Vault-managed and read by the Admin status view; mapping in + code avoids a second token secret and drift between two sections. diff --git a/Fuchs/Docs/INVOICE_LIFECYCLE.md b/Fuchs/Docs/INVOICE_LIFECYCLE.md index a3f5cc0..c27bae6 100644 --- a/Fuchs/Docs/INVOICE_LIFECYCLE.md +++ b/Fuchs/Docs/INVOICE_LIFECYCLE.md @@ -216,7 +216,9 @@ flowchart TD ## 6. Sending the invoice by email Email delivery is handled by `IComService` / `ProcessWebComService`, which -talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`). +talks to the **ProcessWeb Mailer API** (`POST /api/mailer/push_com`, +`comType="e"`; contract: https://processweb.de/api/mailer/fc, client side +described in `Docs/Concepts/processweb-mailer-interface.md`). - `HandleRequestSconf` builds the HTML body via `BuildInvoiceBody(balance, paymentTerms)` — a fixed German thank-you text, the amount (if non-zero), @@ -234,8 +236,12 @@ talks to the **ProcessWeb Mailer API** (`push_com`, `comType=email`). - A signature is appended to the HTML body. - If the mailer is disabled (`_settings.Enabled == false`), sending is a documented no-op. - - Attachments are base64-encoded inline (`{ filename, mimeType, - contentBase64 }`) and POSTed to the Mailer API. + - Attachments are pre-validated against the Mailer's limits (size, count, + no empty file, extension rules) and POSTed as `multipart/form-data` — + envelope JSON in an unnamed field plus one file part per attachment, with + `attachmentCount` in the envelope. + - The Mailer's 200 response must confirm `attachmentsProcessed` equal to + the number sent; otherwise the send is reported as failed (ADR 0028). - Only on a **successful** send does the controller call `fds__setInvoiceSent @auto=true`, which sets `IsSent = 1` — so a failed mailer call leaves the invoice finalised but *not marked sent* (visible via @@ -252,8 +258,10 @@ sequenceDiagram C->>Com: SendEmailAsync("inv_", subject, body, email, "", {pdf}) Com->>Com: validate email, apply OverrideRecipient (dev), append signature alt mailer enabled - Com->>API: POST push_com {comType:"email", attachments:[base64 pdf]} - API-->>Com: success/failure + Com->>Com: MailerAttachmentPolicy.Validate(pdf) + Com->>API: POST /api/mailer/push_com (multipart: envelope{comType:"e", attachmentCount:1} + pdf part) + API-->>Com: 200 {comStackID, attachmentsProcessed} / 4xx/5xx + Com->>Com: attachmentsProcessed == 1 ? success : failure else mailer disabled Com-->>Com: no-op (logged) end diff --git a/Fuchs/Observability/FuchsTelemetry.cs b/Fuchs/Observability/FuchsTelemetry.cs index 63ead72..164dc84 100644 --- a/Fuchs/Observability/FuchsTelemetry.cs +++ b/Fuchs/Observability/FuchsTelemetry.cs @@ -51,6 +51,15 @@ public static class FuchsTelemetry Meter.CreateCounter("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage."); public static readonly Counter BlobUploadsFailed = Meter.CreateCounter("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage."); + public static readonly Counter BlobBackupCopiesSucceeded = + Meter.CreateCounter("fuchs.blobstorage.backupcopies", "{copy}", + "Number of archived blobs successfully copied to a backup name (admin invoice-file backup/clear)."); + public static readonly Counter BlobBackupCopiesFailed = + Meter.CreateCounter("fuchs.blobstorage.backupcopies.failed", "{copy}", + "Number of blob backup-copy attempts that failed."); + public static readonly Counter InvoiceFileBackups = + Meter.CreateCounter("fuchs.invoice.filebackups", "{backup}", + "Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome."); public static readonly Counter SystemProbes = Meter.CreateCounter("fuchs.systemstatus.probes", "{probe}", "Admin system-status connectivity probes executed, tagged by component and outcome."); diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs index 5f189dc..26b0bc4 100644 --- a/Fuchs/Program.cs +++ b/Fuchs/Program.cs @@ -8,9 +8,11 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using OCORE.MultiLogger; using OCORE.security; using OCORE.web; +using OCORE.web.pwmailer; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -136,8 +138,22 @@ public class Program builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor(); - // Communication service (email + SMS via ProcessWeb Mailer API) + // Communication service (email + SMS via ProcessWeb Mailer API). Transport is the OCORE_web + // PWMailer client (ADR 0029); its options are mapped from the existing Fuchs:Mailer section + // rather than a second config section, so the Key Vault-managed Fuchs:Mailer:Token keeps working + // (secret Fuchs--Mailer--Token also named as the on-demand fallback for the placeholder case). builder.Services.Configure(builder.Configuration.GetSection("Fuchs:Mailer")); + builder.Services.AddOptions() + .Configure>((o, s) => + { + o.BaseUrl = s.Value.BaseUrl.TrimEnd('/') + "/api/mailer"; + o.Account = s.Value.AccountId; + o.Token = s.Value.Token; + o.TokenSecretName = "Fuchs--Mailer--Token"; + o.ServerId = s.Value.ServerId; + o.UserAgent = "Fuchs Intranet"; + }); + builder.Services.AddPWMailerClient(); // Dev/test safety net: Fuchs:Email:OverrideRecipient redirects every outbound email // (see appsettings.Development.json) so real tenant-owners/end-customers are never emailed. builder.Services.Configure(builder.Configuration.GetSection("Fuchs:Email")); @@ -148,7 +164,6 @@ public class Program builder.Services.AddSingleton(); // external EN16931 + PDF/A-3 check builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName, c => c.Timeout = TimeSpan.FromSeconds(90)); - builder.Services.AddHttpClient("ProcessWebMailer"); // Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist- // restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018). builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName, @@ -166,6 +181,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Fuchs/Services/AzureBlobStorageService.cs b/Fuchs/Services/AzureBlobStorageService.cs index 0d9ccc1..a44a054 100644 --- a/Fuchs/Services/AzureBlobStorageService.cs +++ b/Fuchs/Services/AzureBlobStorageService.cs @@ -68,6 +68,60 @@ public class AzureBlobStorageService : IBlobStorageService byte[] content, IReadOnlyDictionary? sourceRow = null, CancellationToken cancellationToken = default) => UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken); + public Task CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, + CancellationToken cancellationToken = default) + => CopyToBackupAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, cancellationToken); + + private async Task CopyToBackupAsync(string category, string containerName, string documentId, + string fileName, CancellationToken cancellationToken) + { + if (_client == null) return null; + + string blobName = BuildBlobName(documentId, fileName); + using var act = FuchsTelemetry.StartActivity("blobstorage.copytobackup"); + act?.SetTag("fuchs.blobstorage.category", category); + act?.SetTag("fuchs.blobstorage.id", documentId); + + try + { + var containerClient = _client.GetBlobContainerClient(containerName); + var sourceBlob = containerClient.GetBlobClient(blobName); + Response exists = await sourceBlob.ExistsAsync(cancellationToken); + if (!exists.Value) + { + _logger.LogDebug( + "Blob backup-copy skipped for {Category} {Id} — no existing blob at {Blob}.", + category, documentId, blobName); + return null; + } + + string backupBlobFileName = string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName; + string backupName = $"{documentId}/backup_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{backupBlobFileName}"; + var backupBlob = containerClient.GetBlobClient(backupName); + // Server-side copy: the bytes never pass through this process. A private source in the + // same storage account is authorised by the request's own Shared Key credential, so no + // SAS is needed. WaitForCompletionAsync throws if the copy ends in a failed/aborted state. + // The original is deliberately left in place — it stays the "current" archive until the + // next regeneration overwrites it, and nothing can be half-renamed. + CopyFromUriOperation copy = await backupBlob.StartCopyFromUriAsync( + sourceBlob.Uri, (BlobCopyFromUriOptions?)null, cancellationToken); + await copy.WaitForCompletionAsync(cancellationToken); + + FuchsTelemetry.BlobBackupCopiesSucceeded.Add(1, new KeyValuePair("category", category)); + _logger.LogInformation("Copied {Category} {Id} blob {Old} to backup {New}.", + category, documentId, blobName, backupName); + return backupBlob.Uri; + } + catch (Exception ex) + { + FuchsTelemetry.BlobBackupCopiesFailed.Add(1, new KeyValuePair("category", category)); + act?.SetStatus(ActivityStatusCode.Error, ex.Message); + _logger.LogError(ex, "Blob backup-copy failed for {Category} {Id} ({Blob}).", + category, documentId, blobName); + return null; + } + } + public async Task ExistsAsync(string containerName, string documentId, string fileName, CancellationToken cancellationToken = default) { diff --git a/Fuchs/Services/FuchsMailerClient.cs b/Fuchs/Services/FuchsMailerClient.cs new file mode 100644 index 0000000..34c8585 --- /dev/null +++ b/Fuchs/Services/FuchsMailerClient.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OCORE.web; +using OCORE.web.pwmailer; + +namespace Fuchs.Services; + +/// +/// The OCORE_web with Fuchs' pre-validation: on top of the +/// contract rules the base applies, every outbound message is checked against the Mailer's +/// attachment limits (, configured under +/// Fuchs:Mailer:Attachments) before any bytes are sent. Registered as the application's +/// in Program.cs; options are mapped from +/// Fuchs:Mailer () there. +/// +public class FuchsMailerClient : PWMailerClientService +{ + private readonly MailerAttachmentLimits _limits; + + public FuchsMailerClient(IOptions options, IOptions settings, + IHttpClientFactory httpClientFactory, ILogger logger, + IOCORE_web_securityservice? securityService = null) + : base(options, httpClientFactory, logger, securityService) + { + _limits = settings.Value.Attachments; + } + + protected override IReadOnlyList ValidateMessage(PWMailerMessage message) + { + var problems = new List(base.ValidateMessage(message)); + problems.AddRange(MailerAttachmentPolicy.Validate(message.Attachments, _limits)); + return problems; + } +} diff --git a/Fuchs/Services/IBlobStorageService.cs b/Fuchs/Services/IBlobStorageService.cs index 28bf226..3a35fa6 100644 --- a/Fuchs/Services/IBlobStorageService.cs +++ b/Fuchs/Services/IBlobStorageService.cs @@ -37,6 +37,16 @@ public interface IBlobStorageService Task UploadDocumentAsync(string category, string containerName, string documentId, string fileName, byte[] content, IReadOnlyDictionary? sourceRow = null, CancellationToken cancellationToken = default); + /// + /// Server-side copies an existing invoice PDF blob to a timestamped backup name in the same + /// container; the original blob is left in place. Called before an invoice's stored file is + /// cleared: the next regenerate-on-read (ADR 0025) uploads under the original blob name with + /// overwrite: true, and the backup copy is what survives that. Returns the backup + /// blob's URI, or null when there was nothing to back up, storage is disabled/unconfigured, + /// or the copy failed — logged, never throws. + /// + Task CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, CancellationToken cancellationToken = default); + /// /// Returns true if a blob already exists for the given container/document/filename /// combination. Used by the startup backfill to skip documents that were already archived. diff --git a/Fuchs/Services/IComService.cs b/Fuchs/Services/IComService.cs index 037458a..1949ae8 100644 --- a/Fuchs/Services/IComService.cs +++ b/Fuchs/Services/IComService.cs @@ -2,7 +2,9 @@ /// /// Abstraction for outbound communication: email and SMS. -/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/mailer/push_com). +/// Backed by the ProcessWeb Mailer API (POST {BaseUrl}/api/mailer/push_com); contract at +/// https://processweb.de/api/mailer/fc, client-side description in +/// Docs/Concepts/processweb-mailer-interface.md. /// public interface IComService { @@ -14,9 +16,15 @@ public interface IComService /// HTML body content (signature is appended automatically). /// Recipient email address. /// Recipient display name. - /// Optional file attachments (filename → content). + /// + /// Optional file attachments (filename → content). Validated against the Mailer's limits + /// () before sending; any violation fails the send. + /// /// Optional Cc recipient (e.g. a customer's "Zusatzempfänger"). - /// true when the email was accepted successfully. + /// + /// true only when the Mailer accepted the message and confirmed that every + /// attachment was processed (attachmentsProcessed equals the number sent). + /// Task SendEmailAsync(string reference, string subject, string html, string email, string name, Dictionary? attachments = null, string? cc = null); @@ -30,7 +38,7 @@ public interface IComService /// /// Verifies the configured account/token against the Mailer API's credential-check - /// endpoint (GET {BaseUrl}/mailer/fc?fn=verify) without sending anything. + /// endpoint (GET {BaseUrl}/api/mailer/verify) without sending anything. /// Task VerifyCredentialsAsync(CancellationToken cancellationToken = default); } diff --git a/Fuchs/Services/IInvoiceFileBackupService.cs b/Fuchs/Services/IInvoiceFileBackupService.cs new file mode 100644 index 0000000..cd7f32f --- /dev/null +++ b/Fuchs/Services/IInvoiceFileBackupService.cs @@ -0,0 +1,32 @@ +using OCORE.security; + +namespace Fuchs.Services; + +/// +/// Admin-only: backs up an invoice's stored PDF and clears it so the document regenerates on the +/// next read (ADR 0025). See Docs/Decisions/0027-admin-invoice-file-backup-and-clear.md for why +/// this is a dedicated service/proc rather than reusing the manual-only +/// _BackupAndClearInvoiceFile maintenance tool. +/// +public interface IInvoiceFileBackupService +{ + /// + /// Backs up the invoice's current stored file (DB row via + /// fds__admin_backupAndClearInvoiceFile, plus a best-effort rename-to-backup of the + /// archived blob) and clears the stored file. + /// + Task BackupAndClearAsync(string id, string userAccountId, DatabaseSecurity dbSec, + CancellationToken cancellationToken = default); +} + +/// Outcome of . +public sealed class InvoiceFileBackupResult +{ + public bool Success { get; init; } + + /// Machine-readable reason when is false. + public string? Reason { get; init; } + + /// The archived blob's backup URI, when the blob rename succeeded (best-effort — can be null even on success). + public Uri? BlobBackupUri { get; init; } +} diff --git a/Fuchs/Services/ISystemStatusService.cs b/Fuchs/Services/ISystemStatusService.cs index db6f1fd..ba209e8 100644 --- a/Fuchs/Services/ISystemStatusService.cs +++ b/Fuchs/Services/ISystemStatusService.cs @@ -22,7 +22,11 @@ public interface ISystemStatusService /// /// Sends a test email through the normal pipeline, so the /// dev/test recipient override () applies - /// exactly as for any other outbound email. + /// exactly as for any other outbound email. With (the default) + /// a small letter-layout PDF rendered from the test mail's subject/body is attached, so the + /// Mailer's attachment path (multipart + attachmentsProcessed confirmation, ADR 0028) + /// is exercised too; false sends a plain text-only mail. /// - Task SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default); + Task SendTestEmailAsync(string to, string subject, string body, + bool attachPdf = true, CancellationToken cancellationToken = default); } diff --git a/Fuchs/Services/InvoiceFileBackupService.cs b/Fuchs/Services/InvoiceFileBackupService.cs new file mode 100644 index 0000000..89373bd --- /dev/null +++ b/Fuchs/Services/InvoiceFileBackupService.cs @@ -0,0 +1,131 @@ +using Fuchs.intranet; +using Fuchs.Observability; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; +using OCORE.security; +using OCORE.SQL; +using static OCORE.commons; +using static OCORE.SQL.sql; + +namespace Fuchs.Services; + +/// See . +public class InvoiceFileBackupService : IInvoiceFileBackupService +{ + private static readonly EventId BackupClearEvent = new(0, "AdminBackupClearInvoiceFile"); + + private readonly Fuchs_intranet _intranet; + private readonly IInvoiceService _invoices; + private readonly IBlobStorageService _blobStorage; + private readonly ILogger _logger; + + public InvoiceFileBackupService(Fuchs_intranet intranet, IInvoiceService invoices, + IBlobStorageService blobStorage, ILogger logger) + { + _intranet = intranet; + _invoices = invoices; + _blobStorage = blobStorage; + _logger = logger; + } + + private string Conn => _intranet.Intranet__SQLConnectionString; + + public async Task BackupAndClearAsync(string id, string userAccountId, + DatabaseSecurity dbSec, CancellationToken cancellationToken = default) + { + var invoice = await _invoices.LoadInvoiceAsync(id, userAccountId, dbSec); + // LoadInvoiceAsync always yields a registration (an empty dictionary for an unknown id), + // so "not found" is an empty Id, never a null registration. + if (invoice.InvoiceRegistration == null || string.IsNullOrEmpty(invoice.Id)) + { + _logger.LogWarning(BackupClearEvent, + "BackupAndClearAsync: invoice {Id} not found, user={User}", id, userAccountId); + FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair("outcome", "invoice_not_found")); + return new InvoiceFileBackupResult { Success = false, Reason = "invoice_not_found" }; + } + + // Same precondition as regenerate-on-read (ADR 0025): only a finalised, non-external + // invoice may have its file cleared. Clearing a draft's file would let the next read + // persist a draft render through fds__setInvoiceFile (WHERE [file] IS NULL), which then + // permanently blocks the real final PDF; an external (MFR) document has no template to + // regenerate from, so clearing it would just leave a permanent 404. + bool isExternal = invoice.InvoiceRegistration.getItem("IsExternal", false) is true; + if (invoice.IsDraft || isExternal) + { + string reason = invoice.IsDraft ? "not_final" : "external"; + _logger.LogWarning(BackupClearEvent, + "BackupAndClearAsync: refused for {Id} ({Reason}) — only finalised, non-external invoices can be cleared, user={User}", + invoice.Id, reason, userAccountId); + FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair("outcome", reason)); + return new InvoiceFileBackupResult { Success = false, Reason = reason }; + } + + // fds__getInvoice reports hasFile; without a stored file there is nothing to back up or + // clear, so stop here — before the blob copy — rather than leaving a spare backup_* blob + // behind on every repeated click on an already-cleared invoice. The proc re-checks + // [file] IS NOT NULL itself (OUTPUT row), so a race between this read and the clear is + // still reported as no_file, just with one extra blob copy. + if (invoice.InvoiceRegistration.getItem("hasFile", false) is not true) + { + _logger.LogWarning(BackupClearEvent, + "BackupAndClearAsync: no file stored for {Id} — nothing to back up or clear, user={User}", + invoice.Id, userAccountId); + FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair("outcome", "no_file")); + return new InvoiceFileBackupResult { Success = false, Reason = "no_file" }; + } + + string fileName = invoice.InvoiceRegistration.getString("DocumentName").ne($"Rechnung_{invoice.Id}.pdf"); + + // Copy (not move) the archived blob to a backup name BEFORE the DB clear and leave the + // original in place: the backup therefore exists before any regenerate-on-read (ADR 0025) + // could overwrite the original name, and a DB failure/no-op below leaves the canonical + // archive blob untouched (only a spare copy remains — harmless). Best-effort: a failure + // here is logged, the DB backup row remains the authoritative copy. + Uri? blobBackupUri; + try + { + blobBackupUri = await _blobStorage.CopyInvoicePdfToBackupAsync(invoice.Id, fileName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(BackupClearEvent, ex, + "BackupAndClearAsync: blob backup-copy threw for {Id}, user={User}", invoice.Id, userAccountId); + blobBackupUri = null; + } + + var pl = new List + { + SQL_VarChar("@authuser", userAccountId), + SQL_VarChar("@Id", invoice.Id), + }; + // fds__admin_backupAndClearInvoiceFile writes WHERE [file] IS NOT NULL and returns the + // cleared Id via its OUTPUT clause, so an empty result set means nothing was backed + // up/cleared (invoice missing, or file already empty) — rows-affected can't be used for + // this because the proc runs SET NOCOUNT ON (same reasoning as fds__setInvoiceFile, ADR 0025). + var cleared = await getSQLDatatable_async( + "EXECUTE [dbo].[fds__admin_backupAndClearInvoiceFile] @authuser, @Id;", + Conn, pl, Security: dbSec, options: new FIS_SQLOptions()); + if (!string.IsNullOrEmpty(cleared.Exception)) + { + _logger.LogError(BackupClearEvent, + "BackupAndClearAsync: fds__admin_backupAndClearInvoiceFile failed for {Id}: {Error}, user={User}", + invoice.Id, cleared.Exception, userAccountId); + FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair("outcome", "db_error")); + return new InvoiceFileBackupResult { Success = false, Reason = "db_error", BlobBackupUri = blobBackupUri }; + } + if (cleared.Count == 0) + { + _logger.LogWarning(BackupClearEvent, + "BackupAndClearAsync: no file to back up/clear for {Id} (already empty or invoice missing), user={User}", + invoice.Id, userAccountId); + FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair("outcome", "no_file")); + return new InvoiceFileBackupResult { Success = false, Reason = "no_file", BlobBackupUri = blobBackupUri }; + } + + _logger.LogWarning(BackupClearEvent, + "Admin backed up and cleared invoice file for {Id}, blobBackup={BlobBackupUri}, user={User}", + invoice.Id, blobBackupUri, userAccountId); + FuchsTelemetry.InvoiceFileBackups.Add(1, new KeyValuePair("outcome", "cleared")); + return new InvoiceFileBackupResult { Success = true, BlobBackupUri = blobBackupUri }; + } +} diff --git a/Fuchs/Services/MailerAttachmentPolicy.cs b/Fuchs/Services/MailerAttachmentPolicy.cs new file mode 100644 index 0000000..4aacaa0 --- /dev/null +++ b/Fuchs/Services/MailerAttachmentPolicy.cs @@ -0,0 +1,54 @@ +using OCORE.web.pwmailer; + +namespace Fuchs.Services; + +/// +/// Fuchs-side pre-validation of email attachments against the ProcessWeb Mailer API contract +/// (https://processweb.de/api/mailer/fc, "Attachment Rules"): file count, per-file and per-message +/// size, and the server's forbidden-extension list. The rules the server enforces unconditionally +/// (empty file, missing extension, required fields) are already covered by +/// ; this adds the limit rules on top — see +/// . Pure — no I/O — so it is unit-testable. +/// +public static class MailerAttachmentPolicy +{ + /// + /// Extensions the Mailer rejects (case-insensitive). The contract lists these explicitly and + /// states there are further ones; the server remains authoritative for anything not listed. + /// + internal static readonly HashSet ForbiddenExtensions = new(StringComparer.OrdinalIgnoreCase) + { + "ade", "adp", "app", "application", "appx", "aspx", "bas", "bat", "cab", "cer", "chm", "cmd", + "com", "cpl", "crt", "csh", "dll", "drv", "exe", "fxp", "jar", "js", "jnlp", "msi", "ps1", + "psc1", "psd1", "py", "pyc", "reg", "scf", "scr", "sys", "vbs", "wsf", "xll", + }; + + /// Returns every limit rule the attachments violate, in a stable order; empty when acceptable. + public static IReadOnlyList Validate(IReadOnlyList attachments, MailerAttachmentLimits limits) + { + var violations = new List(); + if (attachments.Count == 0) return violations; + + if (attachments.Count > limits.MaxCount) + violations.Add($"Too many attachments: {attachments.Count} (max {limits.MaxCount})"); + + long total = 0; + foreach (var a in attachments) + { + if (a == null) continue; + long size = a.Bytes?.LongLength ?? 0; + total += size; + if (size > limits.MaxFileBytes) + violations.Add($"Attachment '{a.FileName}' is {size:N0} bytes (max {limits.MaxFileBytes:N0})"); + + string ext = Path.GetExtension(Path.GetFileName(a.FileName ?? "")).TrimStart('.'); + if (ext.Length > 0 && ForbiddenExtensions.Contains(ext)) + violations.Add($"Attachment '{a.FileName}' has a forbidden extension (.{ext.ToLowerInvariant()})"); + } + + if (total > limits.MaxMessageBytes) + violations.Add($"Attachments total {total:N0} bytes (max {limits.MaxMessageBytes:N0} per message)"); + + return violations; + } +} diff --git a/Fuchs/Services/ProcessWebComService.cs b/Fuchs/Services/ProcessWebComService.cs index 66c3fb0..4b4af72 100644 --- a/Fuchs/Services/ProcessWebComService.cs +++ b/Fuchs/Services/ProcessWebComService.cs @@ -1,6 +1,4 @@ using System.Diagnostics; -using System.Net.Http.Headers; -using System.Text; using Fuchs.intranet; using Fuchs.Observability; using Microsoft.Data.SqlClient; @@ -8,13 +6,17 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Newtonsoft.Json; using OCORE.SQL; +using OCORE.web.pwmailer; using static OCORE.SQL.sql; namespace Fuchs.Services; /// -/// Outbound communication service backed by the ProcessWeb Mailer API -/// (POST https://api.processweb.de/mailer/push_com). +/// Outbound communication service: the Fuchs-specific parts of sending (recipient override safety +/// net, signature, attachment limits, fds__emaillog audit, telemetry) on top of the OCORE_web +/// , which owns the ProcessWeb Mailer API transport (request +/// shapes, auth, error headers, retries). Contract: https://processweb.de/api/mailer/fc — see +/// Docs/Concepts/processweb-mailer-interface.md and ADRs 0028/0029. /// When ProcessWebComSettings.Enabled is false the service /// only logs the intended communication without calling the API. /// @@ -24,7 +26,7 @@ public class ProcessWebComService : IComService private readonly Fuchs_intranet _intranet; private readonly ProcessWebComSettings _settings; private readonly FuchsEmailSettings _emailSettings; - private readonly IHttpClientFactory _httpClientFactory; + private readonly IPWMailerClientService _mailer; private const string SignatureIntro = "

 

" + @@ -36,13 +38,13 @@ public class ProcessWebComService : IComService Fuchs_intranet intranet, IOptions settings, IOptions emailSettings, - IHttpClientFactory httpClientFactory) + IPWMailerClientService mailer) { _logger = logger; _intranet = intranet; _settings = settings.Value; _emailSettings = emailSettings.Value; - _httpClientFactory = httpClientFactory; + _mailer = mailer; } public async Task SendEmailAsync(string reference, string subject, string html, @@ -94,47 +96,83 @@ public class ProcessWebComService : IComService bool success = false; string messageId = ""; + string failureReason = "api-error"; var errors = new List(); var sw = Stopwatch.StartNew(); try { - // Attachments are transmitted inline as base64 in the same push_com POST. - // Each entry: { filename, mimeType, contentBase64 }. - var attachmentPayload = (attachments ?? new Dictionary()) - .Where(kv => kv.Value is { Length: > 0 }) - .Select(kv => new - { - filename = kv.Key, - mimeType = GuessMimeType(kv.Key), - contentBase64 = Convert.ToBase64String(kv.Value) - }) - .ToArray(); - - var communication = new + var message = new PWMailerMessage { - to = email, - cc, - subject, - body, - attachments = attachmentPayload + To = email, Cc = cc, Subject = subject, Body = body, Reference = reference, ComType = "e" }; + foreach (var (fileName, bytes) in attachments ?? new Dictionary()) + message.Attachments.Add(new PWMailerAttachment(fileName, bytes)); _logger.LogDebug("SendEmailAsync ref={Reference} to={Email} cc={Cc} attachments={Count}", - reference, email, cc ?? "", attachmentPayload.Length); + reference, email, cc ?? "", message.Attachments.Count); - var (ok, status, responseBody) = await PostToApiAsync(reference, "e", communication); - if (ok) + // PushAsync never throws for HTTP/transport/validation problems; the result says what happened. + // Pre-validation (contract rules + Fuchs attachment limits, see FuchsMailerClient) runs inside + // it and is reported as a refused call: StatusCode 0 and no attempt made. + var result = await _mailer.PushAsync(message); + if (result.Success) { - success = true; - messageId = Guid.NewGuid().ToString("N"); + var receipt = result.Value!; + if (message.Attachments.Count > 0 && receipt.AttachmentsProcessed != message.Attachments.Count) + { + // A 200 alone does not prove the files made it: the contract reports how many + // attachments were received and actually processed. Anything but an exact match + // is a failed send from the caller's point of view. + failureReason = "attachments-mismatch"; + errors.Add($"Mailer accepted the message but not all {message.Attachments.Count} attachment(s): " + + $"attachmentsReceived={receipt.AttachmentsReceived}, attachmentsProcessed={receipt.AttachmentsProcessed}"); + _logger.LogError("SendEmailAsync: attachment count mismatch for {Reference}: sent={Sent} received={Received} processed={Processed} comStackID={ComStackId}", + reference, message.Attachments.Count, receipt.AttachmentsReceived, receipt.AttachmentsProcessed, receipt.ComStackID); + } + else + { + success = true; + } + // comStackID is what getComStatus/getCom accept, so it is the audit key either way. + messageId = receipt.ComStackID; + } + else if (result.IsConflict) + { + // Idempotency guard: an identical message (same server/reference/content) is still + // pending at the Mailer and WILL be delivered; this one was not queued a second time. + // From the caller's point of view the mail is on its way — a resend clicked twice + // must not surface as a send error — so this is a success with a note in the audit log. + success = true; + errors.Add($"Duplicate of a still-pending entry (409) — the earlier entry will be sent, nothing new was queued: {result.Error}"); + _logger.LogInformation("SendEmailAsync: duplicate of pending entry for {Reference}, earlier entry will be sent: {Error}", + reference, result.Error); + } + else if (result.Attempts == 0 && result.StatusCode == 0 && result.Exception is ArgumentException) + { + // Refused by message/attachment pre-validation (PWMailerClientService.ValidateMessage, + // incl. FuchsMailerClient's limits): the client wraps the rule messages in an + // ArgumentException. A caller problem — one audit line per rule. + failureReason = "validation"; + errors.AddRange(result.Error.Split("; ", StringSplitOptions.RemoveEmptyEntries)); + _logger.LogWarning("SendEmailAsync: refused by pre-validation for {Reference}: {Error}", reference, result.Error); + } + else if (result.Attempts == 0 && result.StatusCode == 0) + { + // Refused before sending for an operational reason: unusable PWMailer options or a + // token that could not be resolved (Key Vault outage, missing secret). Nothing the + // caller did — an Error, and its own metric reason, so it is never mistaken for a + // bad attachment. + failureReason = "client-config"; + errors.Add($"Mailer client not usable: {result.Error}"); + _logger.LogError("SendEmailAsync: mailer client not usable for {Reference}: {Error}", reference, result.Error); } else { - // Persist the mailer's raw response so an unsuccessful send is diagnosable from the log. - errors.Add($"API error {(int)status} ({status}): {responseBody}"); - _logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Body}", - reference, (int)status, responseBody); + // Persist the mailer's error so an unsuccessful send is diagnosable from the log. + errors.Add($"API error {result.StatusCode}: {result.Error}"); + _logger.LogWarning("SendEmailAsync API error for {Reference}: {Status} {Error}", + reference, result.StatusCode, result.Error); } } catch (Exception ex) @@ -150,7 +188,7 @@ public class ProcessWebComService : IComService FuchsTelemetry.EmailSendDuration.Record(sw.Elapsed.TotalMilliseconds, new KeyValuePair("success", success)); if (success) FuchsTelemetry.EmailsSent.Add(1); - else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair("reason", "api-error")); + else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair("reason", failureReason)); _logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms", reference, success, sw.ElapsedMilliseconds); @@ -177,21 +215,18 @@ public class ProcessWebComService : IComService try { - var communication = new + var result = await _mailer.PushAsync(new PWMailerMessage { - to = mobile, - body = message - }; - - var (ok, status, responseBody) = await PostToApiAsync(mobile, "s", communication); - if (ok) + To = mobile, Body = message, Reference = mobile, ComType = "s" + }); + if (result.Success) { FuchsTelemetry.SmsSent.Add(1); _logger.LogInformation("SendSmsAsync sent to {Mobile}", mobile); return true; } - _logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Body}", mobile, (int)status, responseBody); + _logger.LogWarning("SendSmsAsync API error for {Mobile}: {Status} {Error}", mobile, result.StatusCode, result.Error); return false; } catch (Exception ex) @@ -206,85 +241,33 @@ public class ProcessWebComService : IComService if (string.IsNullOrWhiteSpace(_settings.AccountId) || string.IsNullOrWhiteSpace(_settings.Token)) return new MailerCredentialsCheckResult { Ok = false, Detail = "Konto-ID oder Token nicht konfiguriert." }; - var client = _httpClientFactory.CreateClient("ProcessWebMailer"); - string credentials = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}")); - // Passing serverid makes the Mailer API also confirm that THIS server is one of the // account's usable servers, not just that the account/token pair is valid. - string url = $"{_settings.BaseUrl}/mailer/fc?fn=verify"; - if (!string.IsNullOrWhiteSpace(_settings.ServerId)) - url += $"&serverid={Uri.EscapeDataString(_settings.ServerId)}"; + string? serverId = string.IsNullOrWhiteSpace(_settings.ServerId) ? null : _settings.ServerId; + var result = await _mailer.VerifyAsync(serverId, cancellationToken); - using var request = new HttpRequestMessage(HttpMethod.Get, url); - request.Headers.Authorization = new AuthenticationHeaderValue("Basic", credentials); - - try + if (!result.Success) { - using var response = await client.SendAsync(request, cancellationToken); - string body = await response.Content.ReadAsStringAsync(cancellationToken); - - if (!response.IsSuccessStatusCode) - return new MailerCredentialsCheckResult - { - Ok = false, - Detail = $"API-Fehler {(int)response.StatusCode} ({response.StatusCode}): {body}", - }; - - var payload = JsonConvert.DeserializeObject(body); - bool ok = payload?.Ok == true; - string? detail = ok ? null - : (!string.IsNullOrWhiteSpace(payload?.ServerId) - ? $"Server-ID '{payload!.ServerId}' gehört nicht zu diesem Mailer-Konto." - : "Mailer API meldete ungültige Zugangsdaten."); + if (result.IsTransportError) _logger.LogWarning("VerifyCredentialsAsync failed: {Error}", result.Error); return new MailerCredentialsCheckResult { - Ok = ok, - Account = payload?.Account, - ServerId = payload?.ServerId, - Detail = detail, + Ok = false, + Detail = result.IsTransportError ? result.Error : $"API-Fehler {result.StatusCode}: {result.Error}", }; } - catch (Exception ex) + + var payload = result.Value!; + string? detail = payload.Ok ? null + : (!string.IsNullOrWhiteSpace(payload.ServerId) + ? $"Server-ID '{payload.ServerId}' gehört nicht zu diesem Mailer-Konto." + : "Mailer API meldete ungültige Zugangsdaten."); + return new MailerCredentialsCheckResult { - _logger.LogWarning(ex, "VerifyCredentialsAsync failed"); - return new MailerCredentialsCheckResult { Ok = false, Detail = ex.Message }; - } - } - - private sealed class MailerVerifyResponse - { - public bool Ok { get; set; } - public string? Account { get; set; } - public string? ServerId { get; set; } - } - - // ── Private helpers ──────────────────────────────────────────────────────── - - private async Task<(bool ok, System.Net.HttpStatusCode status, string body)> PostToApiAsync(string reference, string comtype, object communication) - { - var client = _httpClientFactory.CreateClient("ProcessWebMailer"); - - // The Mailer API expects a fixed envelope: { serverid, comtype, communication, reference }. - // "communication" carries the actual channel-specific payload (to/subject/body/attachments). - var payload = new - { - serverid = _settings.ServerId, - comtype, - communication, - reference + Ok = payload.Ok, + Account = string.IsNullOrEmpty(payload.Account) ? null : payload.Account, + ServerId = payload.ServerId, + Detail = detail, }; - var json = JsonConvert.SerializeObject(payload); - var content = new StringContent(json, Encoding.UTF8, "application/json"); - - string credentials = Convert.ToBase64String( - Encoding.UTF8.GetBytes($"{_settings.AccountId}:{_settings.Token}")); - client.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Basic", credentials); - - var response = await client.PostAsync($"{_settings.BaseUrl}/mailer/push_com", content); - string responseBody = await response.Content.ReadAsStringAsync(); - return (response.IsSuccessStatusCode, response.StatusCode, responseBody); } ///

@@ -375,24 +358,6 @@ public class ProcessWebComService : IComService return ""; } - private static string GuessMimeType(string filename) => - Path.GetExtension(filename).ToLowerInvariant() switch - { - ".pdf" => "application/pdf", - ".png" => "image/png", - ".jpg" or ".jpeg" => "image/jpeg", - ".gif" => "image/gif", - ".txt" => "text/plain", - ".csv" => "text/csv", - ".xml" => "application/xml", - ".zip" => "application/zip", - ".doc" => "application/msword", - ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xls" => "application/vnd.ms-excel", - ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - _ => "application/octet-stream" - }; - private static bool IsValidEmail(string email) { try diff --git a/Fuchs/Services/ProcessWebComSettings.cs b/Fuchs/Services/ProcessWebComSettings.cs index 2eba3ea..71368f7 100644 --- a/Fuchs/Services/ProcessWebComSettings.cs +++ b/Fuchs/Services/ProcessWebComSettings.cs @@ -1,12 +1,13 @@ -namespace Fuchs.Services; +namespace Fuchs.Services; /// /// Settings for the ProcessWeb Mailer API, bound from appsettings.json → "Fuchs:Mailer". +/// Contract: https://processweb.de/api/mailer/fc (see Docs/Concepts/processweb-mailer-interface.md). /// public class ProcessWebComSettings { - /// Base URL of the ProcessWeb API (e.g. "https://api.processweb.de"). - public string BaseUrl { get; set; } = "https://api.processweb.de"; + /// Base URL of the ProcessWeb host (e.g. "https://processweb.de"); endpoints live under /api/mailer/…. + public string BaseUrl { get; set; } = "https://processweb.de"; /// Account ID used for HTTP Basic authentication. public string AccountId { get; set; } = ""; @@ -23,4 +24,24 @@ public class ProcessWebComSettings /// Set to true to enable live sending. /// public bool Enabled { get; set; } = false; + + /// + /// Client-side mirror of the Mailer API's attachment limits, checked before a push_com call + /// so a violation is reported with a precise reason instead of an opaque 400. Defaults are + /// the contract's documented defaults; the server stays authoritative. + /// + public MailerAttachmentLimits Attachments { get; set; } = new(); +} + +/// See . +public sealed class MailerAttachmentLimits +{ + /// Maximum size of a single attachment in bytes (contract default: 10 MB). + public long MaxFileBytes { get; set; } = 10L * 1024 * 1024; + + /// Maximum total attachment size per message in bytes (contract default: 20 MB). + public long MaxMessageBytes { get; set; } = 20L * 1024 * 1024; + + /// Maximum number of attachments per message (contract default: 10). + public int MaxCount { get; set; } = 10; } diff --git a/Fuchs/Services/SystemStatusModels.cs b/Fuchs/Services/SystemStatusModels.cs index 4ec2ca1..538dcad 100644 --- a/Fuchs/Services/SystemStatusModels.cs +++ b/Fuchs/Services/SystemStatusModels.cs @@ -129,4 +129,10 @@ public sealed class EmailTestResult /// (see ); otherwise null. ///
public string? OverrideRecipient { get; init; } + + /// + /// File name of the PDF that was attached to the test mail, or null when the mail was + /// sent without an attachment (or never reached the Mailer). + /// + public string? AttachmentName { get; init; } } diff --git a/Fuchs/Services/SystemStatusService.cs b/Fuchs/Services/SystemStatusService.cs index b236032..837dd81 100644 --- a/Fuchs/Services/SystemStatusService.cs +++ b/Fuchs/Services/SystemStatusService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using Azure; using Azure.Security.KeyVault.Secrets; +using Fuchs.intranet; using Fuchs.Observability; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; @@ -28,6 +29,7 @@ public sealed class SystemStatusService : ISystemStatusService private readonly IBlobStorageService _blob; private readonly IMfrClientFactory _mfrFactory; private readonly IComService _comService; + private readonly IPdfService _pdf; private readonly ProcessWebComSettings _mailer; private readonly FuchsEmailSettings _email; private readonly AzureBlobStorageSettings _blobSettings; @@ -41,6 +43,7 @@ public sealed class SystemStatusService : ISystemStatusService IBlobStorageService blob, IMfrClientFactory mfrFactory, IComService comService, + IPdfService pdf, IOptions mailer, IOptions email, IOptions blobSettings, @@ -53,6 +56,7 @@ public sealed class SystemStatusService : ISystemStatusService _blob = blob; _mfrFactory = mfrFactory; _comService = comService; + _pdf = pdf; _mailer = mailer.Value; _email = email.Value; _blobSettings = blobSettings.Value; @@ -409,8 +413,11 @@ public sealed class SystemStatusService : ISystemStatusService } } + /// File name of the PDF attached to a test email when attachPdf is requested. + internal const string TestPdfAttachmentName = "Fuchs-Intranet-Testmail.pdf"; + public async Task SendTestEmailAsync(string to, string subject, string body, - CancellationToken cancellationToken = default) + bool attachPdf = true, CancellationToken cancellationToken = default) { to = (to ?? "").Trim(); subject = (subject ?? "").Trim(); @@ -422,14 +429,43 @@ public sealed class SystemStatusService : ISystemStatusService // Body arrives as plain text from the Admin form; wrap the (HTML-encoded) lines in

. string html = "

" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "
") + "

"; - bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments: null); + // Optional PDF attachment: exercises the Mailer's multipart/attachment path end-to-end + // (ADR 0028 — the send only counts as successful when the Mailer confirms the attachment). + // The PDF is the same letter layout the invoices/reminders use, rendered in memory from + // the test mail's own subject/body, so no DB or stored document is involved. + Dictionary? attachments = null; + if (attachPdf) + { + try + { + attachments = new Dictionary + { + [TestPdfAttachmentName] = await RenderTestPdfAsync(to, subject, body), + }; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Admin test email: rendering the PDF attachment failed"); + return new EmailTestResult + { + Sent = false, + Message = $"PDF-Anhang konnte nicht erzeugt werden: {ex.Message}", + RequestedRecipient = to, + }; + } + } + + bool sent = await _comService.SendEmailAsync("admin_test", subject, html, to, "Admin Test", attachments); string? overrideTo = string.IsNullOrWhiteSpace(_email.OverrideRecipient) ? null : _email.OverrideRecipient; + string attachmentNote = attachments is null ? "" : $" (mit PDF-Anhang {TestPdfAttachmentName})"; string message = sent ? (overrideTo is null - ? "Test-E-Mail wurde an den Mailer übergeben." - : $"Test-E-Mail wurde an den Mailer übergeben (durch OverrideRecipient umgeleitet an {overrideTo}).") - : "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert)."; + ? $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote}." + : $"Test-E-Mail wurde an den Mailer übergeben{attachmentNote} (durch OverrideRecipient umgeleitet an {overrideTo}).") + : (attachments is null + ? "Test-E-Mail wurde vom Mailer nicht akzeptiert (ggf. ist der Mailer deaktiviert)." + : "Test-E-Mail wurde vom Mailer nicht akzeptiert oder der PDF-Anhang wurde nicht bestätigt (ggf. ist der Mailer deaktiviert)."); return new EmailTestResult { @@ -437,6 +473,28 @@ public sealed class SystemStatusService : ISystemStatusService Message = message, RequestedRecipient = to, OverrideRecipient = overrideTo, + AttachmentName = attachments is null ? null : TestPdfAttachmentName, }; } + + /// + /// Renders the test mail as a one-page letter (standard Fuchs letterhead) to PDF/A bytes. + /// Pure in-memory: text blocks are built from the request, nothing is loaded or persisted. + /// + private async Task RenderTestPdfAsync(string to, string subject, string body) + { + var tb = new FuchsPdf.FdsTextBlocks + { + Address = new[] { "Test-Empfänger", to }, + AdminRef = "admin_test", + AdminDatumLabel = "Datum", + AdminDatumValue = DateTime.Now, + AdminProvLabel = "Umgebung", + ProvisionPeriod = _environment.EnvironmentName, + Subject = subject, + Body = body.Replace("\r\n", "\n"), + }; + var doc = await _pdf.WriteLetterAsync(tb, draft: false); + return _pdf.DocToPdfBytes(doc); + } } diff --git a/Fuchs/appsettings.Development.json b/Fuchs/appsettings.Development.json index 387490d..4760b76 100644 --- a/Fuchs/appsettings.Development.json +++ b/Fuchs/appsettings.Development.json @@ -28,7 +28,7 @@ "CheckPdfLicense": true }, "Mailer": { - "BaseUrl": "https://api.processweb.de", + "BaseUrl": "https://processweb.de", "AccountId": "82d87114-c8c3-4d33-95e5-4c781a9229ab", "Token": "SHWRQGM4C3P6VAFFG6RA", "ServerId": "87AAB12E-29E0-47AB-8DD1-5F5D49B4AC2E", diff --git a/Fuchs/appsettings.json b/Fuchs/appsettings.json index 7a8d6b9..735ecbd 100644 --- a/Fuchs/appsettings.json +++ b/Fuchs/appsettings.json @@ -42,11 +42,16 @@ "fuchs_intranet_TOTP": "MANAGED_BY_KEYVAULT", "SMS_APIKey": "MANAGED_BY_KEYVAULT", "Mailer": { - "BaseUrl": "https://api.processweb.de", + "BaseUrl": "https://processweb.de", "AccountId": "7da3c1e6-4180-42d6-9a60-5a09d005cce6", "Token": "MANAGED_BY_KEYVAULT", "ServerId": "1192f3bf-57e5-4fd2-bb5d-3b7b983a2d98", - "Enabled": true + "Enabled": true, + "Attachments": { + "MaxFileBytes": 10485760, + "MaxMessageBytes": 20971520, + "MaxCount": 10 + } }, "Email": { "OverrideRecipient": "" diff --git a/Fuchs/bdlconfig.json b/Fuchs/bdlconfig.json index 6423090..5fd58e9 100644 --- a/Fuchs/bdlconfig.json +++ b/Fuchs/bdlconfig.json @@ -173,6 +173,25 @@ ], "minify": { "enabled": true } }, + { + "context": "intranet:specialties", + "outputFileName": "wwwroot/web/fis.specialties.de.js", + "inputFiles": [ + "js/intranet/modules/fis.specialties_txt_de.js", + "js/intranet/modules/fis.specialties.js" + ], + "minify": { "enabled": true } + }, + { + "context": "intranet:specialties", + "outputFileName": "wwwroot/web/fis.specialties.min.css", + "inputFiles": [ + "css/intranet/oci_variables.scss", + "css/intranet/fis_variables.scss", + "js/intranet/modules/fis.specialties.scss" + ], + "minify": { "enabled": true } + }, { "context": "intranet:usr", "outputFileName": "wwwroot/web/fis.usr.de.js", diff --git a/Fuchs/js/intranet/fis_main_go.js b/Fuchs/js/intranet/fis_main_go.js index 8d91624..78c217d 100644 --- a/Fuchs/js/intranet/fis_main_go.js +++ b/Fuchs/js/intranet/fis_main_go.js @@ -2,5 +2,5 @@ $fis.notifications.init(); $fis.draft.init(); $fis.ov(); - $fis.addAdminMenuButtonsIfAuthorized(); + $fis.addAdminMenuButtonsIfAuthorized().then($fis.openModuleFromPath); }); diff --git a/Fuchs/js/intranet/fis_main_menu.js b/Fuchs/js/intranet/fis_main_menu.js index 28a86b4..cd25627 100644 --- a/Fuchs/js/intranet/fis_main_menu.js +++ b/Fuchs/js/intranet/fis_main_menu.js @@ -12,21 +12,23 @@ })(); /* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`) - containing three buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp) and - Systemüberwachung (fn=admin) — all restricted to users whose fds_sys authorization exceeds 4. - One auth check gates the whole dropdown; it (and therefore all module scripts) is only added - after that check resolves, so lower-privileged users never see it or fetch any module's code. - Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */ + containing four buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp), + Systemüberwachung (fn=admin) and Spezialfunktionen (fn=specialties) — all restricted to users + whose fds_sys authorization exceeds 4. One auth check gates the whole dropdown; it (and + therefore all module scripts) is only added after that check resolves, so lower-privileged + users never see it or fetch any module's code. Called from fis_main_go.js once the DOM (and + the initial main menu) is ready. Returns a promise that settles once the menu is final. */ $fis.addAdminMenuButtonsIfAuthorized = function () { - if ($('#m_admin_grp').length > 0) { return; } // idempotent - $fis.getAuth('fds_sys').then(function (auth) { + if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent + return $fis.getAuth('fds_sys').then(function (auth) { if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) { $ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog', itm: [ { lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' }, { lbl: $t.m_usrgrp, id: 'm_usrgrp', fnc: 'init:usrgrp', ico: 'glyphicon glyphicon-list' }, - { lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' } + { lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }, + { lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' } ] }); /* Re-render the main menu cleanly: drop the previously generated menu list @@ -35,4 +37,23 @@ $fis.addAdminMenuButtonsIfAuthorized = function () { $('#mainmenu').ocmsmenu($ocms.ocmsmenu); } }).catch(function () { /* auth lookup failed → leave the menu unchanged */ }); +}; + +/* Deep link: the server renders the same SPA shell for every /{fn} URL (IntranetController.Index + ignores fn), so opening e.g. /specialties directly would otherwise just show the dashboard. + Opens the module named by the first path segment — but only one the user's menu actually + offers (`init:` item, incl. the admin dropdown), so arbitrary paths, /do/… links and + pre-auth flows never trigger a module load. Must run after the admin menu settled. */ +$fis.openModuleFromPath = function () { + let fn = (window.location.pathname.split('/')[1] || '').toLowerCase(); + if (!/^[a-z]+$/.test(fn) || fn === 'home') { return; } + let offered = false; + let scan = function (items) { + $.each(items || [], function (i, it) { + if (it.fnc === 'init:' + fn) { offered = true; } + if (it.itm) { scan(it.itm); } + }); + }; + scan($ocms.ocmsmenu); + if (offered) { $ocms.init(fn); } }; \ No newline at end of file diff --git a/Fuchs/js/intranet/fis_texts_gui_de.js b/Fuchs/js/intranet/fis_texts_gui_de.js index ef7cdc8..24447a1 100644 --- a/Fuchs/js/intranet/fis_texts_gui_de.js +++ b/Fuchs/js/intranet/fis_texts_gui_de.js @@ -8,6 +8,7 @@ , m_admin_status: 'Systemüberwachung' , m_usr: 'Benutzerverwaltung' , m_usrgrp: 'Gruppenverwaltung' + , m_specialties: 'Spezialfunktionen' , rsp: 'Passwort ändern' , pnm: 'Die Passwörter stimmen nicht überein' , cps: 'Das neue Passwort wurde gespeichert.' diff --git a/Fuchs/js/intranet/modules/fis.admin.js b/Fuchs/js/intranet/modules/fis.admin.js index e5b2b6e..3583cdf 100644 --- a/Fuchs/js/intranet/modules/fis.admin.js +++ b/Fuchs/js/intranet/modules/fis.admin.js @@ -187,7 +187,7 @@ let $adm = { ]; if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); } // The pill only turns green once the credentials are verified against the Mailer API - // (probe 'email', GET /mailer/fc?fn=verify) — mailerEnabled alone is not proof they work. + // (probe 'email', GET /api/mailer/verify) — mailerEnabled alone is not proof they work. let card = $adm.probeCard(grid, $adt.mail, 'email', rows, probe); let ft = $$.dc('adm_card_ft', card); $$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg); @@ -221,11 +221,12 @@ let $adm = { $ocms.dlgform([ { name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') }, { name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject }, - { name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body } + { name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }, + { name: 'attachpdf', label: $adt.tm_attachpdf, type: 'bool', required: true, value: true, note: $adt.tm_attachpdf_note } ], { title: $adt.testmail, button: $adt.testmail_btn, - size: [420, 560], + size: [420, 640], addcontent: $$.dc('adm_note').text($adt.tm_hint), url: $ocms.url('admin/testmail'), typedvalues: true, diff --git a/Fuchs/js/intranet/modules/fis.admin_txt_de.js b/Fuchs/js/intranet/modules/fis.admin_txt_de.js index 1713d6b..a2458b5 100644 --- a/Fuchs/js/intranet/modules/fis.admin_txt_de.js +++ b/Fuchs/js/intranet/modules/fis.admin_txt_de.js @@ -75,6 +75,8 @@ let $adt = { , tm_to: 'Empfänger' , tm_subject: 'Betreff' , tm_body: 'Nachricht' + , tm_attachpdf: 'PDF-Anhang mitsenden' + , tm_attachpdf_note: 'Hängt ein aus Betreff und Nachricht erzeugtes Test-PDF (Briefbogen-Layout) an und prüft so auch den Anhang-Pfad des Mailers.' , tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.' , tm_sent: 'Test-E-Mail übergeben.' , tm_failed: 'Test-E-Mail konnte nicht gesendet werden.' diff --git a/Fuchs/js/intranet/modules/fis.specialties.js b/Fuchs/js/intranet/modules/fis.specialties.js new file mode 100644 index 0000000..1d5f535 --- /dev/null +++ b/Fuchs/js/intranet/modules/fis.specialties.js @@ -0,0 +1,97 @@ +/* "Specialties" — admin-only page (URL /specialties, menu Administration → Spezialfunktionen) + that exposes the otherwise hidden maintenance actions as cards with small forms. Each card + calls its real action endpoint (which enforces the fds_sys > 4 gate itself); this module only + renders and reports. Add a card per action in `cards`. */ +let $spc = { + init2: function () { + $ocms.getScript([], function () { $spc.init3(); }); + }, + init3: function () { + $fis.cf(true); + $fis.lf(true); + $('#activemodule').text($spt.mdl); + $('#topbar').ocmsmenu([]); + let frm = $$.dc('spcfrm', $('#contentframe')); + $$.dc('spc_intro', frm).text($spt.intro); + let grid = $$.dc('spc_grid', frm); + $.each($spc.cards, function (i, build) { build(grid); }); + }, + + /* ── building blocks ─────────────────────────────────────────────────── */ + card: function (grid, title, desc) { + let card = $$.dc('spc_card', grid); + let hd = $$.dc('spc_card_hd', card); + $$.s(title).appendTo(hd); + let bd = $$.dc('spc_card_bd', card); + if (desc) { $$.dc('spc_card_desc', bd).text(desc); } + let form = $('', { class: 'spc_form', novalidate: 'novalidate' }).appendTo(bd); + let result = $$.dc('spc_result', bd).hide(); + return { card: card, body: bd, form: form, result: result }; + }, + field: function (form, name, label, options) { + options = options || {}; + let fg = $$.dc('spc_field', form); + let id = 'spc_' + name; + $$.lbl(label, { for: id }).appendTo(fg); + let inp = $$.i({ id: id, name: name, type: options.type || 'text', placeholder: options.placeholder || '' }).appendTo(fg); + if (options.required) { inp.attr('required', 'required'); } + if (options.pattern) { inp.attr('pattern', options.pattern); } + if (options.note) { $$.dc('spc_field_note', fg).text(options.note); } + return inp; + }, + showResult: function (result, ok, lines) { + result.empty().attr('class', 'spc_result ' + (ok ? 'spc_result_ok' : 'spc_result_err')); + $.each([].concat(lines || []), function (i, l) { if (l) { $$.dc('spc_result_line', result).text(l); } }); + result.show(); + }, + /* Wires a card form: validates, confirms, POSTs, reports. `run(values)` must return a + postXT options object without success/error/complete; `report(response)` returns + { ok, lines }. Callbacks run inside jQuery's dispatch and must never throw. */ + submit: function (c, options) { + let btn = $$.bbtn(options.button, 'spc_btn spc_btn_primary').attr('type', 'submit').appendTo($$.dc('spc_actions', c.form)); + c.form.on('submit', function (ev) { + ev.preventDefault(); + let f = c.form[0]; + if (typeof f.reportValidity === 'function' && !f.reportValidity()) { return; } + let values = c.form.serializeObject(true, { typedvalues: false }); + if (typeof options.confirm === 'function' && !confirm(options.confirm(values))) { return; } + c.result.hide(); + btn.prop('disabled', true).text($spt.working); + $ocms.postXT($.extend(options.run(values), { + success: function (r) { + let rep = options.report(r || {}); + $spc.showResult(c.result, rep.ok, rep.lines); + }, + error: function (jqXHR) { + $spc.showResult(c.result, false, [$spt.failed, (jqXHR && jqXHR.internalText) || '']); + }, + complete: function () { btn.prop('disabled', false).text(options.button); } + })); + }); + }, + + /* ── cards ───────────────────────────────────────────────────────────── */ + cards: [ + /* Invoice file backup + clear → POST /do/admin/backupclearinvoicefile/ (ADR 0027). */ + function (grid) { + let c = $spc.card(grid, $spt.ifc_title, $spt.ifc_desc); + $spc.field(c.form, 'id', $spt.ifc_id, { required: true, placeholder: $spt.ifc_id_ph, note: $spt.ifc_id_note, pattern: '[A-Za-z0-9]{1,10}' }); + $spc.submit(c, { + button: $spt.ifc_btn, + confirm: function (v) { return $spt.ifc_confirm.replace('{0}', v.id); }, + run: function (v) { + return { url: $ocms.url('admin/backupclearinvoicefile/' + encodeURIComponent((v.id || '').trim())), method: 'post', data: {} }; + }, + report: function (r) { + let b = r.backup || {}; + if (b.success === true) { + return { ok: true, lines: [$spt.ifc_ok, b.blobBackupUri ? $spt.ifc_ok_blob.replace('{0}', b.blobBackupUri) : $spt.ifc_ok_noblob] }; + } + return { ok: false, lines: [$spt.ifc_reason[b.reason] || ($spt.failed + ' (' + (b.reason || '?') + ')')] }; + } + }); + } + ] +}; +let $$spc = { init2: $spc.init2, auth: {} }; +export default $$spc; diff --git a/Fuchs/js/intranet/modules/fis.specialties.scss b/Fuchs/js/intranet/modules/fis.specialties.scss new file mode 100644 index 0000000..3c4f8c0 --- /dev/null +++ b/Fuchs/js/intranet/modules/fis.specialties.scss @@ -0,0 +1,99 @@ +.spcfrm { + padding: 1.5rem; + + .spc_intro { + margin-bottom: 1rem; + padding: 0.5rem 0.6rem; + background: #f3f6fb; + border-left: 3px solid $fuchs_blau; + font-size: 0.9rem; + color: #444; + } + + .spc_grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr)); + gap: 1rem; + align-items: start; + } + + .spc_card { + border: 1px solid #d5d5d5; + border-radius: 0.4rem; + background: #fff; + box-shadow: 0 1px 3px rgba(50, 50, 50, 0.12); + overflow: hidden; + } + + .spc_card_hd { + padding: 0.6rem 0.85rem; + background: $fuchs_blau; + color: #fff; + font-weight: bold; + } + + .spc_card_bd { + padding: 0.75rem 0.85rem; + } + + .spc_card_desc { + font-size: 0.88rem; + color: #555; + margin-bottom: 0.75rem; + } + + .spc_form { + .spc_field { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-bottom: 0.6rem; + + label { font-size: 0.85rem; color: #333; font-weight: 600; } + + input { + padding: 0.3rem 0.45rem; + border: 1px solid #bbb; + border-radius: 0.25rem; + font-size: 0.95rem; + + &:invalid { border-color: #c0341d; } + } + + .spc_field_note { font-size: 0.78rem; color: #777; } + } + + .spc_actions { text-align: right; margin-top: 0.25rem; } + } + + .spc_btn { + cursor: pointer; + border-radius: 0.28rem; + padding: 0.3rem 0.8rem; + font-size: 0.9rem; + border: 1px solid #ababab; + background-color: #fff; + color: #333; + + &.spc_btn_primary { + background-color: $fuchs_akzent; + border-color: darken($fuchs_akzent, 8%); + color: #fff; + } + + &:disabled { opacity: 0.6; cursor: default; } + } + + .spc_result { + margin-top: 0.75rem; + padding: 0.5rem 0.6rem; + border-radius: 0.25rem; + font-size: 0.88rem; + word-break: break-word; + + .spc_result_line + .spc_result_line { margin-top: 0.25rem; } + + &.spc_result_ok { background: #e2f3da; color: #2f6d18; } + &.spc_result_err { background: #f3ded9; color: #c0341d; } + } +} diff --git a/Fuchs/js/intranet/modules/fis.specialties_txt_de.js b/Fuchs/js/intranet/modules/fis.specialties_txt_de.js new file mode 100644 index 0000000..d129a26 --- /dev/null +++ b/Fuchs/js/intranet/modules/fis.specialties_txt_de.js @@ -0,0 +1,26 @@ +let $spt = { + mdl: 'Spezialfunktionen', + intro: 'Versteckte Wartungsfunktionen für Administratoren. Jede Aktion wird protokolliert (fds__admin_activity / Anwendungslog).', + run: 'Ausführen', + working: 'Wird ausgeführt…', + failed: 'Die Aktion ist fehlgeschlagen.', + + /* Card: invoice file backup + clear */ + ifc_title: 'Rechnungsdatei zurücksetzen (Soft-Delete)', + ifc_desc: 'Sichert die gespeicherte PDF-Datei einer finalisierten Rechnung (DB-Tabelle fds__invoice_file_backups + Kopie im Blob-Archiv) und entfernt sie aus der Rechnung. Beim nächsten Aufruf des Dokuments wird die PDF neu generiert (ADR 0025/0027).', + ifc_id: 'Interne Rechnungs-ID', + ifc_id_ph: 'z.B. W7VAL', + ifc_id_note: 'Die interne ID (Spalte Id), nicht die Rechnungsnummer R2026-….', + ifc_btn: 'Datei sichern und entfernen', + ifc_confirm: 'Die Rechnungsdatei von "{0}" wird gesichert und aus der Rechnung entfernt. Fortfahren?', + ifc_ok: 'Erledigt: Die Datei wurde gesichert und entfernt. Das Dokument wird beim nächsten Aufruf neu generiert.', + ifc_ok_blob: 'Blob-Backup: {0}', + ifc_ok_noblob: 'Kein Blob-Backup angelegt (Blob-Archiv deaktiviert, kein Blob vorhanden oder Kopie fehlgeschlagen — siehe Log).', + ifc_reason: { + invoice_not_found: 'Rechnung nicht gefunden.', + not_final: 'Die Rechnung ist nicht finalisiert — nur finalisierte Rechnungen können zurückgesetzt werden (Entwürfe würden sonst dauerhaft als Entwurf gespeichert).', + external: 'Externe (MFR-)Dokumente können nicht neu generiert werden.', + no_file: 'Es ist keine Datei gespeichert — nichts zu tun.', + db_error: 'Datenbankfehler — Details im Anwendungslog (CodeReference AdminBackupClearInvoiceFile).' + } +}; diff --git a/Fuchs/wwwroot/web/fis.admin.de.js b/Fuchs/wwwroot/web/fis.admin.de.js index 789f7bb..ade9f57 100644 --- a/Fuchs/wwwroot/web/fis.admin.de.js +++ b/Fuchs/wwwroot/web/fis.admin.de.js @@ -75,6 +75,8 @@ let $adt = { , tm_to: 'Empfänger' , tm_subject: 'Betreff' , tm_body: 'Nachricht' + , tm_attachpdf: 'PDF-Anhang mitsenden' + , tm_attachpdf_note: 'Hängt ein aus Betreff und Nachricht erzeugtes Test-PDF (Briefbogen-Layout) an und prüft so auch den Anhang-Pfad des Mailers.' , tm_hint: 'Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.' , tm_sent: 'Test-E-Mail übergeben.' , tm_failed: 'Test-E-Mail konnte nicht gesendet werden.' @@ -272,7 +274,7 @@ let $adm = { ]; if (e.overrideActive === true) { rows.push([$adt.override, e.overrideRecipient]); } // The pill only turns green once the credentials are verified against the Mailer API - // (probe 'email', GET /mailer/fc?fn=verify) — mailerEnabled alone is not proof they work. + // (probe 'email', GET /api/mailer/verify) — mailerEnabled alone is not proof they work. let card = $adm.probeCard(grid, $adt.mail, 'email', rows, probe); let ft = $$.dc('adm_card_ft', card); $$.bbtn($adt.testmail_btn, 'adm_btn adm_btn_primary').appendTo(ft).click($adm.testMailDlg); @@ -306,11 +308,12 @@ let $adm = { $ocms.dlgform([ { name: 'to', label: $adt.tm_to, type: 'email', required: true, value: ($ocms.auth.email || '') }, { name: 'subject', label: $adt.tm_subject, type: 'string', required: true, value: $adt.tm_default_subject }, - { name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body } + { name: 'body', label: $adt.tm_body, type: 'text', required: true, value: $adt.tm_default_body }, + { name: 'attachpdf', label: $adt.tm_attachpdf, type: 'bool', required: true, value: true, note: $adt.tm_attachpdf_note } ], { title: $adt.testmail, button: $adt.testmail_btn, - size: [420, 560], + size: [420, 640], addcontent: $$.dc('adm_note').text($adt.tm_hint), url: $ocms.url('admin/testmail'), typedvalues: true, diff --git a/Fuchs/wwwroot/web/fis.admin.de.min.js b/Fuchs/wwwroot/web/fis.admin.de.min.js index 0a7baa5..af24813 100644 --- a/Fuchs/wwwroot/web/fis.admin.de.min.js +++ b/Fuchs/wwwroot/web/fis.admin.de.min.js @@ -1 +1 @@ -let $adt={mdl:"Systemüberwachung",sub_info:"System",sub_status:"Status & Konnektivität",sub_tests:"Tests",startup:"Startup-Prüfungen",startup_notrun:"Die Startup-Prüfungen sind deaktiviert oder wurden noch nicht ausgeführt (Fuchs:StartupChecks:Enabled).",startup_on:"Host",startup_at:"Ausgeführt (UTC)",chk_ok:"OK",chk_fail:"Fehler",chk_skipped:"übersprungen",chk_KeyVault:"Azure Key Vault",chk_Database:"SQL-Datenbank",chk_MFR:"MFR (ERP)",chk_PdfLicense:"Spire.PDF-Lizenz",chk_Mailer:"E-Mail (Startup-Probe)",host:"Host / Server",machine:"Hostname",os:"Betriebssystem",framework:"Framework",env:"Umgebung",testdep:"Test-Deployment",pid:"Prozess-ID",uptime:"Laufzeit",since:"Start (UTC)",hours:"Std.",db:"SQL-Datenbank",mail:"E-Mail-Dienst",blob:"Azure Blob Storage",mfr:"MFR (ERP)",kv:"Azure Key Vault",server:"Server",catalog:"Datenbank",login:"Login",mailer:"Mailer aktiv",baseurl:"Base-URL",account:"Account",serverid:"Server-ID",token:"Token hinterlegt",override:"OverrideRecipient",overrideActive:"Umleitung aktiv",enabled:"Aktiviert",configured:"Konfiguriert",invContainer:"Rechnungs-Container",remContainer:"Mahnungs-Container",host_l:"Host",creds:"Zugangsdaten hinterlegt",sync:"Sync aktiv",vault:"Vault-URI",appname:"App-Präfix",seccount:"Verwaltete Secrets",clientreg:"Client registriert",duration:"Dauer",checked:"Geprüft",detail:"Detail",st_ok:"OK",st_error:"Fehler",st_disabled:"Deaktiviert",st_unconfigured:"Nicht konfiguriert",st_checking:"Prüfe…",refresh:"Aktualisieren",refreshAll:"Alle prüfen",yes:"Ja",no:"Nein",testmail:"Test-E-Mail",testmail_btn:"Test-E-Mail senden",tm_to:"Empfänger",tm_subject:"Betreff",tm_body:"Nachricht",tm_hint:"Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.",tm_sent:"Test-E-Mail übergeben.",tm_failed:"Test-E-Mail konnte nicht gesendet werden.",tm_default_subject:"Fuchs Intranet – Test-E-Mail",tm_default_body:"Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.",loadfail:"Der Systemstatus konnte nicht geladen werden."},$adm={init2:function(){$ocms.getScript([],(function(){$adm.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($adt.mdl),$adm.topbar();let e=$$.dc("admfrm",$("#contentframe")).ldng(1);$adm.load(e)},topbar:function(){return $("#topbar").ocmsmenu([{lbl:$adt.refreshAll,id:"adm_refresh_all",ico:"glyphicon glyphicon-refresh",fnc:function(){$adm.init3()}}])},load:function(e){$ocms.postXT({url:$ocms.url("admin/status"),success:function(t){e.empty(),$adm.render(e,t.info||{},t.probes||[])},error:function(){e.empty(),$$.dc("adm_err",e).text($adt.loadfail)},complete:function(){e.ldng(0)}})},render:function(e,t,a){let d={};$.each(a,(function(e,t){d[t.component]=t})),$$.dc("adm_sec",e).text($adt.sub_info),$adm.sysCard(e,t),$adm.startupCard(e,t.startupChecks),$$.dc("adm_sec",e).text($adt.sub_status);let r=$$.dc("adm_grid",e);$adm.dbCard(r,t.database||{},d.database),$adm.mailCard(r,t.email||{},d.email),$adm.blobCard(r,t.blob||{},d.blob),$adm.mfrCard(r,t.mfr||{},d.mfr),$adm.kvCard(r,t.keyVault||{},d.keyvault)},kvTable:function(e,t){let a=$$.tbl().addClass("adm_kv");return $.each(t||[],(function(e,t){if(!t)return!0;let d=$$.tr(a);$$.td(d).addClass("k").text(null==t[0]?"":String(t[0]));let r=$$.td(d).addClass("v");t[1]instanceof jQuery?r.append(t[1]):r.text(null==t[1]?"":String(t[1]))})),e instanceof jQuery&&e.append(a),a},yesNo:function(e){return $$.sc("adm_bool adm_bool_"+(e?"y":"n"),e?$adt.yes:$adt.no)},setPill:function(e,t){let a=(t=t||{}).status||"unconfigured",d={ok:$adt.st_ok,error:$adt.st_error,disabled:$adt.st_disabled,unconfigured:$adt.st_unconfigured,checking:$adt.st_checking}[a]||a;return e.attr("class","adm_pill adm_pill_"+a).text(d),e},probeCard:function(e,t,a,d,r){let n=$$.dc("adm_card",e),i=$$.dc("adm_card_hd",n);$$.s(t).appendTo(i);let s=$$.sc("adm_pill").appendTo(i),o=$$.bbtn($adt.refresh,"adm_btn").appendTo(i),c=$$.dc("adm_card_bd",n);$adm.kvTable(c,d);let l=$$.dc("adm_probe",c),m=$$.dc("adm_probe_msg",l),u=$$.dc("adm_probe_metrics",l),p=$$.dc("adm_probe_det",l),_=$$.dc("adm_probe_dur",l),b=function(e){e=e||{},$adm.setPill(s,e),m.text(e.message||""),u.empty(),$.each(e.metrics||[],(function(e,t){let a=$$.dc("adm_metric",u);$$.sc("adm_metric_k").text(t.label||"").appendTo(a),$$.sc("adm_metric_v").text(null==t.value?"":String(t.value)).appendTo(a)})),p.text(e.detail||""),_.text(null!=e.durationMs?e.durationMs+" ms":"")};return b(r),o.click((function(){$adm.setPill(s,{status:"checking"}),m.text(""),u.empty(),p.text(""),_.text(""),$ocms.postXT({url:$ocms.url("admin/probe/"+a),success:function(e){b(e.probe)},error:function(){$adm.setPill(s,{status:"error"}),m.text($adt.st_error)}})})),n},sysCard:function(e,t){let a=$$.dc("adm_card adm_card_wide",e),d=$$.dc("adm_card_hd",a);$$.s($adt.host).appendTo(d);let r=$$.sc("adm_env").text(t.environment||"");!0===t.isTestDeployment&&r.aC("adm_env_test"),r.appendTo(d);let n=$$.dc("adm_card_bd",a);$adm.kvTable(n,[[$adt.machine,t.machineName],[$adt.os,t.osDescription],[$adt.framework,t.frameworkDescription],[$adt.env,t.environment],[$adt.testdep,$adm.yesNo(!0===t.isTestDeployment)],[$adt.pid,t.processId],[$adt.since,t.processStartUtc?fdt(t.processStartUtc):""],[$adt.uptime,null!=t.uptimeHours?t.uptimeHours+" "+$adt.hours:""]])},startupCard:function(e,t){let a=$$.dc("adm_card adm_card_wide",e),d=$$.dc("adm_card_hd",a);$$.s($adt.startup).appendTo(d);let r=$$.dc("adm_card_bd",a);if(!t||!0!==t.ran)return $adm.setPill($$.sc("adm_pill").appendTo(d),{status:"disabled"}),void $$.dc("adm_note",r).text($adt.startup_notrun);let n=t.items||[],i=!1;$.each(n,(function(e,t){!0===t.enabled&&!0!==t.ok&&(i=!0)})),$adm.setPill($$.sc("adm_pill").appendTo(d),{status:i?"error":"ok"}),$adm.kvTable(r,[[$adt.startup_on,t.machineName],[$adt.startup_at,t.completedUtc?fdt(t.completedUtc):""]]);let s=$$.dc("adm_checks",r);$.each(n,(function(e,t){let a=$$.dc("adm_check",s);$$.sc("adm_check_k").text($adt["chk_"+t.name]||t.name).appendTo(a);let d=!0!==t.enabled?"skipped":!0===t.ok?"ok":"fail",r={ok:$adt.chk_ok,fail:$adt.chk_fail,skipped:$adt.chk_skipped}[d];$$.sc("adm_check_v adm_check_"+d).text(r).appendTo(a)}))},dbCard:function(e,t,a){$adm.probeCard(e,$adt.db,"database",[[$adt.configured,$adm.yesNo(!0===t.configured)],[$adt.server,t.server],[$adt.catalog,t.catalog],[$adt.login,t.userId]],a)},mailCard:function(e,t,a){let d=[[$adt.mailer,$adm.yesNo(!0===t.mailerEnabled)],[$adt.baseurl,t.baseUrl],[$adt.account,t.accountId],[$adt.serverid,t.serverId],[$adt.token,$adm.yesNo(!0===t.tokenConfigured)],[$adt.overrideActive,$adm.yesNo(!0===t.overrideActive)]];!0===t.overrideActive&&d.push([$adt.override,t.overrideRecipient]);let r=$adm.probeCard(e,$adt.mail,"email",d,a),n=$$.dc("adm_card_ft",r);$$.bbtn($adt.testmail_btn,"adm_btn adm_btn_primary").appendTo(n).click($adm.testMailDlg)},blobCard:function(e,t,a){$adm.probeCard(e,$adt.blob,"blob",[[$adt.enabled,$adm.yesNo(!0===t.enabled)],[$adt.configured,$adm.yesNo(!0===t.configured)],[$adt.invContainer,t.invoiceContainer],[$adt.remContainer,t.reminderContainer]],a)},mfrCard:function(e,t,a){$adm.probeCard(e,$adt.mfr,"mfr",[[$adt.host_l,t.host],[$adt.creds,$adm.yesNo(!0===t.credentialsConfigured)],[$adt.sync,$adm.yesNo(!0===t.syncEnabled)]],a)},kvCard:function(e,t,a){$adm.probeCard(e,$adt.kv,"keyvault",[[$adt.vault,t.vaultUri],[$adt.appname,t.appName],[$adt.seccount,t.managedSecretCount],[$adt.clientreg,$adm.yesNo(!0===t.clientRegistered)]],a)},testMailDlg:function(){$ocms.dlgform([{name:"to",label:$adt.tm_to,type:"email",required:!0,value:$ocms.auth.email||""},{name:"subject",label:$adt.tm_subject,type:"string",required:!0,value:$adt.tm_default_subject},{name:"body",label:$adt.tm_body,type:"text",required:!0,value:$adt.tm_default_body}],{title:$adt.testmail,button:$adt.testmail_btn,size:[420,560],addcontent:$$.dc("adm_note").text($adt.tm_hint),url:$ocms.url("admin/testmail"),typedvalues:!0,success:function(e){let t=e&&e.result||{};alert(t.message||(!0===t.sent?$adt.tm_sent:$adt.tm_failed))}})}},$$adm={init2:$adm.init2,auth:{}};export default $$adm; \ No newline at end of file +let $adt={mdl:"Systemüberwachung",sub_info:"System",sub_status:"Status & Konnektivität",sub_tests:"Tests",startup:"Startup-Prüfungen",startup_notrun:"Die Startup-Prüfungen sind deaktiviert oder wurden noch nicht ausgeführt (Fuchs:StartupChecks:Enabled).",startup_on:"Host",startup_at:"Ausgeführt (UTC)",chk_ok:"OK",chk_fail:"Fehler",chk_skipped:"übersprungen",chk_KeyVault:"Azure Key Vault",chk_Database:"SQL-Datenbank",chk_MFR:"MFR (ERP)",chk_PdfLicense:"Spire.PDF-Lizenz",chk_Mailer:"E-Mail (Startup-Probe)",host:"Host / Server",machine:"Hostname",os:"Betriebssystem",framework:"Framework",env:"Umgebung",testdep:"Test-Deployment",pid:"Prozess-ID",uptime:"Laufzeit",since:"Start (UTC)",hours:"Std.",db:"SQL-Datenbank",mail:"E-Mail-Dienst",blob:"Azure Blob Storage",mfr:"MFR (ERP)",kv:"Azure Key Vault",server:"Server",catalog:"Datenbank",login:"Login",mailer:"Mailer aktiv",baseurl:"Base-URL",account:"Account",serverid:"Server-ID",token:"Token hinterlegt",override:"OverrideRecipient",overrideActive:"Umleitung aktiv",enabled:"Aktiviert",configured:"Konfiguriert",invContainer:"Rechnungs-Container",remContainer:"Mahnungs-Container",host_l:"Host",creds:"Zugangsdaten hinterlegt",sync:"Sync aktiv",vault:"Vault-URI",appname:"App-Präfix",seccount:"Verwaltete Secrets",clientreg:"Client registriert",duration:"Dauer",checked:"Geprüft",detail:"Detail",st_ok:"OK",st_error:"Fehler",st_disabled:"Deaktiviert",st_unconfigured:"Nicht konfiguriert",st_checking:"Prüfe…",refresh:"Aktualisieren",refreshAll:"Alle prüfen",yes:"Ja",no:"Nein",testmail:"Test-E-Mail",testmail_btn:"Test-E-Mail senden",tm_to:"Empfänger",tm_subject:"Betreff",tm_body:"Nachricht",tm_attachpdf:"PDF-Anhang mitsenden",tm_attachpdf_note:"Hängt ein aus Betreff und Nachricht erzeugtes Test-PDF (Briefbogen-Layout) an und prüft so auch den Anhang-Pfad des Mailers.",tm_hint:"Der Versand nutzt die normale E-Mail-Pipeline; bei gesetztem OverrideRecipient wird die Nachricht dorthin umgeleitet.",tm_sent:"Test-E-Mail übergeben.",tm_failed:"Test-E-Mail konnte nicht gesendet werden.",tm_default_subject:"Fuchs Intranet – Test-E-Mail",tm_default_body:"Dies ist eine Test-E-Mail aus dem Administrationsbereich des Fuchs Intranet.",loadfail:"Der Systemstatus konnte nicht geladen werden."},$adm={init2:function(){$ocms.getScript([],(function(){$adm.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($adt.mdl),$adm.topbar();let e=$$.dc("admfrm",$("#contentframe")).ldng(1);$adm.load(e)},topbar:function(){return $("#topbar").ocmsmenu([{lbl:$adt.refreshAll,id:"adm_refresh_all",ico:"glyphicon glyphicon-refresh",fnc:function(){$adm.init3()}}])},load:function(e){$ocms.postXT({url:$ocms.url("admin/status"),success:function(t){e.empty(),$adm.render(e,t.info||{},t.probes||[])},error:function(){e.empty(),$$.dc("adm_err",e).text($adt.loadfail)},complete:function(){e.ldng(0)}})},render:function(e,t,a){let d={};$.each(a,(function(e,t){d[t.component]=t})),$$.dc("adm_sec",e).text($adt.sub_info),$adm.sysCard(e,t),$adm.startupCard(e,t.startupChecks),$$.dc("adm_sec",e).text($adt.sub_status);let n=$$.dc("adm_grid",e);$adm.dbCard(n,t.database||{},d.database),$adm.mailCard(n,t.email||{},d.email),$adm.blobCard(n,t.blob||{},d.blob),$adm.mfrCard(n,t.mfr||{},d.mfr),$adm.kvCard(n,t.keyVault||{},d.keyvault)},kvTable:function(e,t){let a=$$.tbl().addClass("adm_kv");return $.each(t||[],(function(e,t){if(!t)return!0;let d=$$.tr(a);$$.td(d).addClass("k").text(null==t[0]?"":String(t[0]));let n=$$.td(d).addClass("v");t[1]instanceof jQuery?n.append(t[1]):n.text(null==t[1]?"":String(t[1]))})),e instanceof jQuery&&e.append(a),a},yesNo:function(e){return $$.sc("adm_bool adm_bool_"+(e?"y":"n"),e?$adt.yes:$adt.no)},setPill:function(e,t){let a=(t=t||{}).status||"unconfigured",d={ok:$adt.st_ok,error:$adt.st_error,disabled:$adt.st_disabled,unconfigured:$adt.st_unconfigured,checking:$adt.st_checking}[a]||a;return e.attr("class","adm_pill adm_pill_"+a).text(d),e},probeCard:function(e,t,a,d,n){let r=$$.dc("adm_card",e),i=$$.dc("adm_card_hd",r);$$.s(t).appendTo(i);let s=$$.sc("adm_pill").appendTo(i),o=$$.bbtn($adt.refresh,"adm_btn").appendTo(i),c=$$.dc("adm_card_bd",r);$adm.kvTable(c,d);let l=$$.dc("adm_probe",c),m=$$.dc("adm_probe_msg",l),u=$$.dc("adm_probe_metrics",l),p=$$.dc("adm_probe_det",l),_=$$.dc("adm_probe_dur",l),f=function(e){e=e||{},$adm.setPill(s,e),m.text(e.message||""),u.empty(),$.each(e.metrics||[],(function(e,t){let a=$$.dc("adm_metric",u);$$.sc("adm_metric_k").text(t.label||"").appendTo(a),$$.sc("adm_metric_v").text(null==t.value?"":String(t.value)).appendTo(a)})),p.text(e.detail||""),_.text(null!=e.durationMs?e.durationMs+" ms":"")};return f(n),o.click((function(){$adm.setPill(s,{status:"checking"}),m.text(""),u.empty(),p.text(""),_.text(""),$ocms.postXT({url:$ocms.url("admin/probe/"+a),success:function(e){f(e.probe)},error:function(){$adm.setPill(s,{status:"error"}),m.text($adt.st_error)}})})),r},sysCard:function(e,t){let a=$$.dc("adm_card adm_card_wide",e),d=$$.dc("adm_card_hd",a);$$.s($adt.host).appendTo(d);let n=$$.sc("adm_env").text(t.environment||"");!0===t.isTestDeployment&&n.aC("adm_env_test"),n.appendTo(d);let r=$$.dc("adm_card_bd",a);$adm.kvTable(r,[[$adt.machine,t.machineName],[$adt.os,t.osDescription],[$adt.framework,t.frameworkDescription],[$adt.env,t.environment],[$adt.testdep,$adm.yesNo(!0===t.isTestDeployment)],[$adt.pid,t.processId],[$adt.since,t.processStartUtc?fdt(t.processStartUtc):""],[$adt.uptime,null!=t.uptimeHours?t.uptimeHours+" "+$adt.hours:""]])},startupCard:function(e,t){let a=$$.dc("adm_card adm_card_wide",e),d=$$.dc("adm_card_hd",a);$$.s($adt.startup).appendTo(d);let n=$$.dc("adm_card_bd",a);if(!t||!0!==t.ran)return $adm.setPill($$.sc("adm_pill").appendTo(d),{status:"disabled"}),void $$.dc("adm_note",n).text($adt.startup_notrun);let r=t.items||[],i=!1;$.each(r,(function(e,t){!0===t.enabled&&!0!==t.ok&&(i=!0)})),$adm.setPill($$.sc("adm_pill").appendTo(d),{status:i?"error":"ok"}),$adm.kvTable(n,[[$adt.startup_on,t.machineName],[$adt.startup_at,t.completedUtc?fdt(t.completedUtc):""]]);let s=$$.dc("adm_checks",n);$.each(r,(function(e,t){let a=$$.dc("adm_check",s);$$.sc("adm_check_k").text($adt["chk_"+t.name]||t.name).appendTo(a);let d=!0!==t.enabled?"skipped":!0===t.ok?"ok":"fail",n={ok:$adt.chk_ok,fail:$adt.chk_fail,skipped:$adt.chk_skipped}[d];$$.sc("adm_check_v adm_check_"+d).text(n).appendTo(a)}))},dbCard:function(e,t,a){$adm.probeCard(e,$adt.db,"database",[[$adt.configured,$adm.yesNo(!0===t.configured)],[$adt.server,t.server],[$adt.catalog,t.catalog],[$adt.login,t.userId]],a)},mailCard:function(e,t,a){let d=[[$adt.mailer,$adm.yesNo(!0===t.mailerEnabled)],[$adt.baseurl,t.baseUrl],[$adt.account,t.accountId],[$adt.serverid,t.serverId],[$adt.token,$adm.yesNo(!0===t.tokenConfigured)],[$adt.overrideActive,$adm.yesNo(!0===t.overrideActive)]];!0===t.overrideActive&&d.push([$adt.override,t.overrideRecipient]);let n=$adm.probeCard(e,$adt.mail,"email",d,a),r=$$.dc("adm_card_ft",n);$$.bbtn($adt.testmail_btn,"adm_btn adm_btn_primary").appendTo(r).click($adm.testMailDlg)},blobCard:function(e,t,a){$adm.probeCard(e,$adt.blob,"blob",[[$adt.enabled,$adm.yesNo(!0===t.enabled)],[$adt.configured,$adm.yesNo(!0===t.configured)],[$adt.invContainer,t.invoiceContainer],[$adt.remContainer,t.reminderContainer]],a)},mfrCard:function(e,t,a){$adm.probeCard(e,$adt.mfr,"mfr",[[$adt.host_l,t.host],[$adt.creds,$adm.yesNo(!0===t.credentialsConfigured)],[$adt.sync,$adm.yesNo(!0===t.syncEnabled)]],a)},kvCard:function(e,t,a){$adm.probeCard(e,$adt.kv,"keyvault",[[$adt.vault,t.vaultUri],[$adt.appname,t.appName],[$adt.seccount,t.managedSecretCount],[$adt.clientreg,$adm.yesNo(!0===t.clientRegistered)]],a)},testMailDlg:function(){$ocms.dlgform([{name:"to",label:$adt.tm_to,type:"email",required:!0,value:$ocms.auth.email||""},{name:"subject",label:$adt.tm_subject,type:"string",required:!0,value:$adt.tm_default_subject},{name:"body",label:$adt.tm_body,type:"text",required:!0,value:$adt.tm_default_body},{name:"attachpdf",label:$adt.tm_attachpdf,type:"bool",required:!0,value:!0,note:$adt.tm_attachpdf_note}],{title:$adt.testmail,button:$adt.testmail_btn,size:[420,640],addcontent:$$.dc("adm_note").text($adt.tm_hint),url:$ocms.url("admin/testmail"),typedvalues:!0,success:function(e){let t=e&&e.result||{};alert(t.message||(!0===t.sent?$adt.tm_sent:$adt.tm_failed))}})}},$$adm={init2:$adm.init2,auth:{}};export default $$adm; \ No newline at end of file diff --git a/Fuchs/wwwroot/web/fis.js b/Fuchs/wwwroot/web/fis.js index 99fa0f4..113f6bc 100644 --- a/Fuchs/wwwroot/web/fis.js +++ b/Fuchs/wwwroot/web/fis.js @@ -2846,6 +2846,7 @@ $.extend($t, { , m_admin_status: 'Systemüberwachung' , m_usr: 'Benutzerverwaltung' , m_usrgrp: 'Gruppenverwaltung' + , m_specialties: 'Spezialfunktionen' , rsp: 'Passwort ändern' , pnm: 'Die Passwörter stimmen nicht überein' , cps: 'Das neue Passwort wurde gespeichert.' @@ -3242,21 +3243,23 @@ $fis.draft = { })(); /* "Administration" is a single sidemenu dropdown (ocmsmenu supports nested items via `itm`) - containing three buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp) and - Systemüberwachung (fn=admin) — all restricted to users whose fds_sys authorization exceeds 4. - One auth check gates the whole dropdown; it (and therefore all module scripts) is only added - after that check resolves, so lower-privileged users never see it or fetch any module's code. - Called from fis_main_go.js once the DOM (and the initial main menu) is ready. */ + containing four buttons — Benutzerverwaltung (fn=usr), Gruppenverwaltung (fn=usrgrp), + Systemüberwachung (fn=admin) and Spezialfunktionen (fn=specialties) — all restricted to users + whose fds_sys authorization exceeds 4. One auth check gates the whole dropdown; it (and + therefore all module scripts) is only added after that check resolves, so lower-privileged + users never see it or fetch any module's code. Called from fis_main_go.js once the DOM (and + the initial main menu) is ready. Returns a promise that settles once the menu is final. */ $fis.addAdminMenuButtonsIfAuthorized = function () { - if ($('#m_admin_grp').length > 0) { return; } // idempotent - $fis.getAuth('fds_sys').then(function (auth) { + if ($('#m_admin_grp').length > 0) { return Promise.resolve(); } // idempotent + return $fis.getAuth('fds_sys').then(function (auth) { if ((auth || -1) > 4 && $('#m_admin_grp').length === 0) { $ocms.ocmsmenu.push({ lbl: $t.m_admin, id: 'm_admin_grp', ico: 'glyphicon glyphicon-cog', itm: [ { lbl: $t.m_usr, id: 'm_usr', fnc: 'init:usr', ico: 'glyphicon glyphicon-user' }, { lbl: $t.m_usrgrp, id: 'm_usrgrp', fnc: 'init:usrgrp', ico: 'glyphicon glyphicon-list' }, - { lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' } + { lbl: $t.m_admin_status, id: 'm_admin', fnc: 'init:admin', ico: 'glyphicon glyphicon-dashboard' }, + { lbl: $t.m_specialties, id: 'm_specialties', fnc: 'init:specialties', ico: 'glyphicon glyphicon-wrench' } ] }); /* Re-render the main menu cleanly: drop the previously generated menu list @@ -3266,9 +3269,28 @@ $fis.addAdminMenuButtonsIfAuthorized = function () { } }).catch(function () { /* auth lookup failed → leave the menu unchanged */ }); }; + +/* Deep link: the server renders the same SPA shell for every /{fn} URL (IntranetController.Index + ignores fn), so opening e.g. /specialties directly would otherwise just show the dashboard. + Opens the module named by the first path segment — but only one the user's menu actually + offers (`init:` item, incl. the admin dropdown), so arbitrary paths, /do/… links and + pre-auth flows never trigger a module load. Must run after the admin menu settled. */ +$fis.openModuleFromPath = function () { + let fn = (window.location.pathname.split('/')[1] || '').toLowerCase(); + if (!/^[a-z]+$/.test(fn) || fn === 'home') { return; } + let offered = false; + let scan = function (items) { + $.each(items || [], function (i, it) { + if (it.fnc === 'init:' + fn) { offered = true; } + if (it.itm) { scan(it.itm); } + }); + }; + scan($ocms.ocmsmenu); + if (offered) { $ocms.init(fn); } +}; $(document).ready(function () { $fis.notifications.init(); $fis.draft.init(); $fis.ov(); - $fis.addAdminMenuButtonsIfAuthorized(); + $fis.addAdminMenuButtonsIfAuthorized().then($fis.openModuleFromPath); }); diff --git a/Fuchs/wwwroot/web/fis.min.js b/Fuchs/wwwroot/web/fis.min.js index 0cb16d9..af6cb87 100644 --- a/Fuchs/wwwroot/web/fis.min.js +++ b/Fuchs/wwwroot/web/fis.min.js @@ -1,4 +1,4 @@ -var t,e,$t={lng:"de-DE",dn:["So","Mo","Di","Mi","Do","Fr","Sa"],mn:["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],ma:["Jan","Feb","Mrz","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],datepattern:"(0[1-9]|1[0-9]|2[0-9]|3[01]).(0[1-9]|1[012]).[0-9]{4}",datetimepattern:"(0[1-9]|1[0-9]|2[0-9]|3[01]).(0[1-9]|1[012]).[0-9]{4}\\s([0-5][0-9]):([0-5][0-9])",dateplaceholder:"dd.MM.yyyy",datetimeplaceholder:"dd.MM.yyyy HH:mm",dateformat:"dd.MM.yyyy",datetimeformat:"dd.MM.yyyy HH:mm",f1:"Der Server hat einen Fehler gemeldet: \n",f2:"Bitte versuchen Sie es erneut.",m0:"Diese Internet-Seite benötigt einen html5-kompatiblen Browser.",m0b:"Unterstützt werden bspw: Internet Explorer ab Version 10, Firefox ab Version 31, Chrome ab Version 31, Safari ab Version 7, Opera ab Version 27",m1:"Dieser Datensatz ist momentan von jemand anderem zur Bearbeitung gesperrt.",m2:"Diese Funktion ist zur Zeit nicht verfügbar",t1:"Eingabe erforderlich.",t2:"Eingabe ist nicht erforderlich.",true:"Ja",false:"Nein",alert:"Hinweis",confirm:"Bestätigen",open:"Öffnen","not implemented":"Diese Funktion in zur Zeit noch nicht verfügbar.",l0:"Anmeldung",l1:"Email / Anmeldename",l2:"Email-Adresse / Anmeldename",l3:"Passwort",l4:"Benutzer",l5:"Wird vom System ermittelt...",l6:"Anmelden",l7:"Passwort vergessen?",l7a:'Die "Passwort vergessen"-Funktion läuft in zwei Schritten ab:\n \nIm ersten Schritt wird eine SMS mit einem Code an die hinterlegte Mobilfunk-Nummer versandt.\nIm zweiten Schritt geben Sie bitte diesen Code in das Formular ein und übermitteln es erneut.\n \nIn beiden Schritten wird aus Sicherheitsgründen kein Fehler angezeigt und auch dann ein erfolgreicher Versand bestätigt, wenn die Kombination aus Email-Adresse und Nachname nicht gefunden wurde und/oder der code falsch ist.',l8:"Keinen Account?",l9:"Anmeldenamen der Email-Adresse wurde nicht erkannt.",l10:"Nachname",l11:"Email-Adresse",l12:"Passwort zusenden",l13:"Das Passwort wurde erfolgreich verschickt",l14:"Das Passwort konnte nicht verschickt werden",l15:"Sie sind nicht berechtigt, diese Funktion auszuführen.",l16:"Sie müssen zunächst einen Account angeben.",l17:"Die Kombination aus Anmeldenamen und Passwort konnte nicht bestätigt werden.",l18:"Es gibt ein Problem mit dem Formular.\nEs kann momentan nicht verarbeitet und versendet werden.",name:"Name",submit:"Senden",cancel:"Abbrechen",noop:"Diese Funktion is noch nicht verfügar."};t=self,e=()=>(()=>{var t={d:(e,n)=>{for(var i in n)t.o(n,i)&&!t.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:n[i]})}};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),t.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),t.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"t",{value:!0})};var e,n={};t.r(n),t.d(n,{AbortError:()=>r,DefaultHttpClient:()=>D,HttpClient:()=>h,HttpError:()=>i,HttpResponse:()=>d,HttpTransportType:()=>U,HubConnection:()=>O,HubConnectionBuilder:()=>tt,HubConnectionState:()=>j,JsonHubProtocol:()=>K,LogLevel:()=>e,MessageType:()=>N,NullLogger:()=>p,Subject:()=>q,TimeoutError:()=>o,TransferFormat:()=>B,VERSION:()=>f});class i extends Error{constructor(t,e){const n=new.target.prototype;super(`${t}: Status code '${e}'`),this.statusCode=e,this.__proto__=n}}class o extends Error{constructor(t="A timeout occurred."){const e=new.target.prototype;super(t),this.__proto__=e}}class r extends Error{constructor(t="An abort occurred."){const e=new.target.prototype;super(t),this.__proto__=e}}class s extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.transport=e,this.errorType="UnsupportedTransportError",this.__proto__=n}}class a extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.transport=e,this.errorType="DisabledTransportError",this.__proto__=n}}class c extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.transport=e,this.errorType="FailedToStartTransportError",this.__proto__=n}}class l extends Error{constructor(t){const e=new.target.prototype;super(t),this.errorType="FailedToNegotiateWithServerError",this.__proto__=e}}class u extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.innerErrors=e,this.__proto__=n}}class d{constructor(t,e,n){this.statusCode=t,this.statusText=e,this.content=n}}class h{get(t,e){return this.send({...e,method:"GET",url:t})}post(t,e){return this.send({...e,method:"POST",url:t})}delete(t,e){return this.send({...e,method:"DELETE",url:t})}getCookieString(t){return""}}!function(t){t[t.Trace=0]="Trace",t[t.Debug=1]="Debug",t[t.Information=2]="Information",t[t.Warning=3]="Warning",t[t.Error=4]="Error",t[t.Critical=5]="Critical",t[t.None=6]="None"}(e||(e={}));class p{constructor(){}log(t,e){}}p.instance=new p;const f="10.0.0";class m{static isRequired(t,e){if(null==t)throw new Error(`The '${e}' argument is required.`)}static isNotEmpty(t,e){if(!t||t.match(/^\s*$/))throw new Error(`The '${e}' argument should not be empty.`)}static isIn(t,e,n){if(!(t in e))throw new Error(`Unknown ${n} value: ${t}.`)}}class g{static get isBrowser(){return!g.isNode&&"object"==typeof window&&"object"==typeof window.document}static get isWebWorker(){return!g.isNode&&"object"==typeof self&&"importScripts"in self}static get isReactNative(){return!g.isNode&&"object"==typeof window&&void 0===window.document}static get isNode(){return"undefined"!=typeof process&&process.release&&"node"===process.release.name}}function y(t,e){let n="";return v(t)?(n=`Binary data of length ${t.byteLength}`,e&&(n+=`. Content: '${function(t){const e=new Uint8Array(t);let n="";return e.forEach((t=>{n+=`0x${t<16?"0":""}${t.toString(16)} `})),n.substring(0,n.length-1)}(t)}'`)):"string"==typeof t&&(n=`String data of length ${t.length}`,e&&(n+=`. Content: '${t}'`)),n}function v(t){return t&&"undefined"!=typeof ArrayBuffer&&(t instanceof ArrayBuffer||t.constructor&&"ArrayBuffer"===t.constructor.name)}async function b(t,n,i,o,r,s){const a={},[c,l]=C();a[c]=l,t.log(e.Trace,`(${n} transport) sending data. ${y(r,s.logMessageContent)}.`);const u=v(r)?"arraybuffer":"text",d=await i.post(o,{content:r,headers:{...a,...s.headers},responseType:u,timeout:s.timeout,withCredentials:s.withCredentials});t.log(e.Trace,`(${n} transport) request complete. Response status: ${d.statusCode}.`)}class ${constructor(t,e){this.i=t,this.h=e}dispose(){const t=this.i.observers.indexOf(this.h);t>-1&&this.i.observers.splice(t,1),0===this.i.observers.length&&this.i.cancelCallback&&this.i.cancelCallback().catch((t=>{}))}}class w{constructor(t){this.l=t,this.out=console}log(t,n){if(t>=this.l){const i=`[${(new Date).toISOString()}] ${e[t]}: ${n}`;switch(t){case e.Critical:case e.Error:this.out.error(i);break;case e.Warning:this.out.warn(i);break;case e.Information:this.out.info(i);break;default:this.out.log(i)}}}}function C(){let t="X-SignalR-User-Agent";return g.isNode&&(t="User-Agent"),[t,S(f,_(),g.isNode?"NodeJS":"Browser",x())]}function S(t,e,n,i){let o="Microsoft SignalR/";const r=t.split(".");return o+=`${r[0]}.${r[1]}`,o+=` (${t}; `,o+=e&&""!==e?`${e}; `:"Unknown OS; ",o+=`${n}`,o+=i?`; ${i}`:"; Unknown Runtime Version",o+=")",o}function _(){if(!g.isNode)return"";switch(process.platform){case"win32":return"Windows NT";case"darwin":return"macOS";case"linux":return"Linux";default:return process.platform}}function x(){if(g.isNode)return process.versions.node}function T(t){return t.stack?t.stack:t.message?t.message:`${t}`}class E extends h{constructor(e){if(super(),this.u=e,"undefined"==typeof fetch||g.isNode){const t=require;this.p=new(t("tough-cookie").CookieJar),"undefined"==typeof fetch?this.m=t("node-fetch"):this.m=fetch,this.m=t("fetch-cookie")(this.m,this.p)}else this.m=fetch.bind(function(){if("undefined"!=typeof globalThis)return globalThis;if("undefined"!=typeof self)return self;if("undefined"!=typeof window)return window;if(void 0!==t.g)return t.g;throw new Error("could not find global")}());if("undefined"==typeof AbortController){const t=require;this.v=t("abort-controller")}else this.v=AbortController}async send(t){if(t.abortSignal&&t.abortSignal.aborted)throw new r;if(!t.method)throw new Error("No method defined.");if(!t.url)throw new Error("No url defined.");const n=new this.v;let s;t.abortSignal&&(t.abortSignal.onabort=()=>{n.abort(),s=new r});let a,c=null;if(t.timeout){const i=t.timeout;c=setTimeout((()=>{n.abort(),this.u.log(e.Warning,"Timeout from HTTP request."),s=new o}),i)}""===t.content&&(t.content=void 0),t.content&&(t.headers=t.headers||{},v(t.content)?t.headers["Content-Type"]="application/octet-stream":t.headers["Content-Type"]="text/plain;charset=UTF-8");try{a=await this.m(t.url,{body:t.content,cache:"no-cache",credentials:!0===t.withCredentials?"include":"same-origin",headers:{"X-Requested-With":"XMLHttpRequest",...t.headers},method:t.method,mode:"cors",redirect:"follow",signal:n.signal})}catch(t){if(s)throw s;throw this.u.log(e.Warning,`Error from HTTP request. ${t}.`),t}finally{c&&clearTimeout(c),t.abortSignal&&(t.abortSignal.onabort=null)}if(!a.ok){const t=await k(a,"text");throw new i(t||a.statusText,a.status)}const l=k(a,t.responseType),u=await l;return new d(a.status,a.statusText,u)}getCookieString(t){let e="";return g.isNode&&this.p&&this.p.getCookies(t,((t,n)=>e=n.join("; "))),e}}function k(t,e){let n;switch(e){case"arraybuffer":n=t.arrayBuffer();break;case"text":default:n=t.text();break;case"blob":case"document":case"json":throw new Error(`${e} is not supported.`)}return n}class I extends h{constructor(t){super(),this.u=t}send(t){return t.abortSignal&&t.abortSignal.aborted?Promise.reject(new r):t.method?t.url?new Promise(((n,s)=>{const a=new XMLHttpRequest;a.open(t.method,t.url,!0),a.withCredentials=void 0===t.withCredentials||t.withCredentials,a.setRequestHeader("X-Requested-With","XMLHttpRequest"),""===t.content&&(t.content=void 0),t.content&&(v(t.content)?a.setRequestHeader("Content-Type","application/octet-stream"):a.setRequestHeader("Content-Type","text/plain;charset=UTF-8"));const c=t.headers;c&&Object.keys(c).forEach((t=>{a.setRequestHeader(t,c[t])})),t.responseType&&(a.responseType=t.responseType),t.abortSignal&&(t.abortSignal.onabort=()=>{a.abort(),s(new r)}),t.timeout&&(a.timeout=t.timeout),a.onload=()=>{t.abortSignal&&(t.abortSignal.onabort=null),a.status>=200&&a.status<300?n(new d(a.status,a.statusText,a.response||a.responseText)):s(new i(a.response||a.responseText||a.statusText,a.status))},a.onerror=()=>{this.u.log(e.Warning,`Error from HTTP request. ${a.status}: ${a.statusText}.`),s(new i(a.statusText,a.status))},a.ontimeout=()=>{this.u.log(e.Warning,"Timeout from HTTP request."),s(new o)},a.send(t.content)})):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}}class D extends h{constructor(t){if(super(),"undefined"!=typeof fetch||g.isNode)this.$=new E(t);else{if("undefined"==typeof XMLHttpRequest)throw new Error("No usable HttpClient found.");this.$=new I(t)}}send(t){return t.abortSignal&&t.abortSignal.aborted?Promise.reject(new r):t.method?t.url?this.$.send(t):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}getCookieString(t){return this.$.getCookieString(t)}}class A{static write(t){return`${t}${A.RecordSeparator}`}static parse(t){if(t[t.length-1]!==A.RecordSeparator)throw new Error("Message is incomplete.");const e=t.split(A.RecordSeparator);return e.pop(),e}}A.RecordSeparatorCode=30,A.RecordSeparator=String.fromCharCode(A.RecordSeparatorCode);class P{writeHandshakeRequest(t){return A.write(JSON.stringify(t))}parseHandshakeResponse(t){let e,n;if(v(t)){const i=new Uint8Array(t),o=i.indexOf(A.RecordSeparatorCode);if(-1===o)throw new Error("Message is incomplete.");const r=o+1;e=String.fromCharCode.apply(null,Array.prototype.slice.call(i.slice(0,r))),n=i.byteLength>r?i.slice(r).buffer:null}else{const i=t,o=i.indexOf(A.RecordSeparator);if(-1===o)throw new Error("Message is incomplete.");const r=o+1;e=i.substring(0,r),n=i.length>r?i.substring(r):null}const i=A.parse(e),o=JSON.parse(i[0]);if(o.type)throw new Error("Expected a handshake response from the server.");return[n,o]}}var N,j;!function(t){t[t.Invocation=1]="Invocation",t[t.StreamItem=2]="StreamItem",t[t.Completion=3]="Completion",t[t.StreamInvocation=4]="StreamInvocation",t[t.CancelInvocation=5]="CancelInvocation",t[t.Ping=6]="Ping",t[t.Close=7]="Close",t[t.Ack=8]="Ack",t[t.Sequence=9]="Sequence"}(N||(N={}));class q{constructor(){this.observers=[]}next(t){for(const e of this.observers)e.next(t)}error(t){for(const e of this.observers)e.error&&e.error(t)}complete(){for(const t of this.observers)t.complete&&t.complete()}subscribe(t){return this.observers.push(t),new $(this,t)}}class M{constructor(t,e,n){this.C=1e5,this.S=[],this.k=0,this.P=!1,this.T=1,this.I=0,this._=0,this.H=!1,this.D=t,this.R=e,this.C=n}async A(t){const e=this.D.writeMessage(t);let n=Promise.resolve();if(this.U(t)){this.k++;let t=()=>{},i=()=>{};v(e)?this._+=e.byteLength:this._+=e.length,this._>=this.C&&(n=new Promise(((e,n)=>{t=e,i=n}))),this.S.push(new R(e,this.k,t,i))}try{this.H||await this.R.send(e)}catch{this.L()}await n}N(t){let e=-1;for(let n=0;nthis.T?this.R.stop(new Error("Sequence ID greater than amount of messages we've received.")):this.T=t.sequenceId}L(){this.H=!0,this.P=!0}async B(){const t=0!==this.S.length?this.S[0].q:this.k+1;await this.R.send(this.D.writeMessage({type:N.Sequence,sequenceId:t}));const e=this.S;for(const t of e)await this.R.send(t.M);this.H=!1}X(t){null!=t||(t=new Error("Unable to reconnect to server."));for(const e of this.S)e.J(t)}U(t){switch(t.type){case N.Invocation:case N.StreamItem:case N.Completion:case N.StreamInvocation:case N.CancelInvocation:return!0;case N.Close:case N.Sequence:case N.Ping:case N.Ack:return!1}}O(){void 0===this.V&&(this.V=setTimeout((async()=>{try{this.H||await this.R.send(this.D.writeMessage({type:N.Ack,sequenceId:this.I}))}catch{}clearTimeout(this.V),this.V=void 0}),1e3))}}class R{constructor(t,e,n,i){this.M=t,this.q=e,this.j=n,this.J=i}}!function(t){t.Disconnected="Disconnected",t.Connecting="Connecting",t.Connected="Connected",t.Disconnecting="Disconnecting",t.Reconnecting="Reconnecting"}(j||(j={}));class O{static create(t,e,n,i,o,r,s){return new O(t,e,n,i,o,r,s)}constructor(t,n,i,o,r,s,a){this.K=0,this.G=()=>{this.u.log(e.Warning,"The page is being frozen, this will likely lead to the connection being closed and messages being lost. For more information see the docs at https://learn.microsoft.com/aspnet/core/signalr/javascript-client#bsleep")},m.isRequired(t,"connection"),m.isRequired(n,"logger"),m.isRequired(i,"protocol"),this.serverTimeoutInMilliseconds=null!=r?r:3e4,this.keepAliveIntervalInMilliseconds=null!=s?s:15e3,this.Y=null!=a?a:1e5,this.u=n,this.D=i,this.connection=t,this.Z=o,this.tt=new P,this.connection.onreceive=t=>this.et(t),this.connection.onclose=t=>this.st(t),this.it={},this.nt={},this.rt=[],this.ot=[],this.ht=[],this.ct=0,this.lt=!1,this.ut=j.Disconnected,this.dt=!1,this.ft=this.D.writeMessage({type:N.Ping})}get state(){return this.ut}get connectionId(){return this.connection&&this.connection.connectionId||null}get baseUrl(){return this.connection.baseUrl||""}set baseUrl(t){if(this.ut!==j.Disconnected&&this.ut!==j.Reconnecting)throw new Error("The HubConnection must be in the Disconnected or Reconnecting state to change the url.");if(!t)throw new Error("The HubConnection url must be a valid url.");this.connection.baseUrl=t}start(){return this.wt=this.gt(),this.wt}async gt(){if(this.ut!==j.Disconnected)return Promise.reject(new Error("Cannot start a HubConnection that is not in the 'Disconnected' state."));this.ut=j.Connecting,this.u.log(e.Debug,"Starting HubConnection.");try{await this.yt(),g.isBrowser&&window.document.addEventListener("freeze",this.G),this.ut=j.Connected,this.dt=!0,this.u.log(e.Debug,"HubConnection connected successfully.")}catch(t){return this.ut=j.Disconnected,this.u.log(e.Debug,`HubConnection failed to start successfully because of error '${t}'.`),Promise.reject(t)}}async yt(){this.vt=void 0,this.lt=!1;const t=new Promise(((t,e)=>{this.bt=t,this.Et=e}));await this.connection.start(this.D.transferFormat);try{let n=this.D.version;this.connection.features.reconnect||(n=1);const i={protocol:this.D.name,version:n};if(this.u.log(e.Debug,"Sending handshake request."),await this.$t(this.tt.writeHandshakeRequest(i)),this.u.log(e.Information,`Using HubProtocol '${this.D.name}'.`),this.Ct(),this.St(),this.kt(),await t,this.vt)throw this.vt;!!this.connection.features.reconnect&&(this.Pt=new M(this.D,this.connection,this.Y),this.connection.features.disconnected=this.Pt.L.bind(this.Pt),this.connection.features.resend=()=>{if(this.Pt)return this.Pt.B()}),this.connection.features.inherentKeepAlive||await this.$t(this.ft)}catch(t){throw this.u.log(e.Debug,`Hub handshake failed with error '${t}' during start(). Stopping HubConnection.`),this.Ct(),this.Tt(),await this.connection.stop(t),t}}async stop(){const t=this.wt;this.connection.features.reconnect=!1,this.It=this._t(),await this.It;try{await t}catch(t){}}_t(t){if(this.ut===j.Disconnected)return this.u.log(e.Debug,`Call to HubConnection.stop(${t}) ignored because it is already in the disconnected state.`),Promise.resolve();if(this.ut===j.Disconnecting)return this.u.log(e.Debug,`Call to HttpConnection.stop(${t}) ignored because the connection is already in the disconnecting state.`),this.It;const n=this.ut;return this.ut=j.Disconnecting,this.u.log(e.Debug,"Stopping HubConnection."),this.Ht?(this.u.log(e.Debug,"Connection stopped during reconnect delay. Done reconnecting."),clearTimeout(this.Ht),this.Ht=void 0,this.Dt(),Promise.resolve()):(n===j.Connected&&this.Rt(),this.Ct(),this.Tt(),this.vt=t||new r("The connection was stopped before the hub handshake could complete."),this.connection.stop(t))}async Rt(){try{await this.xt(this.At())}catch{}}stream(t,...e){const[n,i]=this.Ut(e),o=this.Lt(t,e,i);let r;const s=new q;return s.cancelCallback=()=>{const t=this.Nt(o.invocationId);return delete this.it[o.invocationId],r.then((()=>this.xt(t)))},this.it[o.invocationId]=(t,e)=>{e?s.error(e):t&&(t.type===N.Completion?t.error?s.error(new Error(t.error)):s.complete():s.next(t.item))},r=this.xt(o).catch((t=>{s.error(t),delete this.it[o.invocationId]})),this.qt(n,r),s}$t(t){return this.kt(),this.connection.send(t)}xt(t){return this.Pt?this.Pt.A(t):this.$t(this.D.writeMessage(t))}send(t,...e){const[n,i]=this.Ut(e),o=this.xt(this.Mt(t,e,!0,i));return this.qt(n,o),o}invoke(t,...e){const[n,i]=this.Ut(e),o=this.Mt(t,e,!1,i);return new Promise(((t,e)=>{this.it[o.invocationId]=(n,i)=>{i?e(i):n&&(n.type===N.Completion?n.error?e(new Error(n.error)):t(n.result):e(new Error(`Unexpected message type: ${n.type}`)))};const i=this.xt(o).catch((t=>{e(t),delete this.it[o.invocationId]}));this.qt(n,i)}))}on(t,e){t&&e&&(t=t.toLowerCase(),this.nt[t]||(this.nt[t]=[]),-1===this.nt[t].indexOf(e)&&this.nt[t].push(e))}off(t,e){if(!t)return;t=t.toLowerCase();const n=this.nt[t];if(n)if(e){const i=n.indexOf(e);-1!==i&&(n.splice(i,1),0===n.length&&delete this.nt[t])}else delete this.nt[t]}onclose(t){t&&this.rt.push(t)}onreconnecting(t){t&&this.ot.push(t)}onreconnected(t){t&&this.ht.push(t)}et(t){if(this.Ct(),this.lt||(t=this.jt(t),this.lt=!0),t){const n=this.D.parseMessages(t,this.u);for(const i of n)if(!this.Pt||this.Pt.W(i))switch(i.type){case N.Invocation:this.Wt(i).catch((t=>{this.u.log(e.Error,`Invoke client method threw error: ${T(t)}`)}));break;case N.StreamItem:case N.Completion:{const n=this.it[i.invocationId];if(n){i.type===N.Completion&&delete this.it[i.invocationId];try{n(i)}catch(t){this.u.log(e.Error,`Stream callback threw error: ${T(t)}`)}}break}case N.Ping:break;case N.Close:{this.u.log(e.Information,"Close message received from server.");const t=i.error?new Error("Server returned an error on close: "+i.error):void 0;!0===i.allowReconnect?this.connection.stop(t):this.It=this._t(t);break}case N.Ack:this.Pt&&this.Pt.N(i);break;case N.Sequence:this.Pt&&this.Pt.F(i);break;default:this.u.log(e.Warning,`Invalid message type: ${i.type}.`)}}this.St()}jt(t){let n,i;try{[i,n]=this.tt.parseHandshakeResponse(t)}catch(t){const n="Error parsing handshake response: "+t;this.u.log(e.Error,n);const i=new Error(n);throw this.Et(i),i}if(n.error){const t="Server returned handshake error: "+n.error;this.u.log(e.Error,t);const i=new Error(t);throw this.Et(i),i}return this.u.log(e.Debug,"Server handshake complete."),this.bt(),i}kt(){this.connection.features.inherentKeepAlive||(this.K=(new Date).getTime()+this.keepAliveIntervalInMilliseconds,this.Tt())}St(){if(!this.connection.features||!this.connection.features.inherentKeepAlive){this.Ot=setTimeout((()=>this.serverTimeout()),this.serverTimeoutInMilliseconds);let t=this.K-(new Date).getTime();if(t<0)return void(this.ut===j.Connected&&this.Ft());void 0===this.Bt&&(t<0&&(t=0),this.Bt=setTimeout((async()=>{this.ut===j.Connected&&await this.Ft()}),t))}}serverTimeout(){this.connection.stop(new Error("Server timeout elapsed without receiving a message from the server."))}async Wt(t){const n=t.target.toLowerCase(),i=this.nt[n];if(!i)return this.u.log(e.Warning,`No client method with the name '${n}' found.`),void(t.invocationId&&(this.u.log(e.Warning,`No result given for '${n}' method and invocation ID '${t.invocationId}'.`),await this.xt(this.Xt(t.invocationId,"Client didn't provide a result.",null))));const o=i.slice(),r=!!t.invocationId;let s,a,c;for(const i of o)try{const o=s;s=await i.apply(this,t.arguments),r&&s&&o&&(this.u.log(e.Error,`Multiple results provided for '${n}'. Sending error to server.`),c=this.Xt(t.invocationId,"Client provided multiple results.",null)),a=void 0}catch(t){a=t,this.u.log(e.Error,`A callback for the method '${n}' threw error '${t}'.`)}c?await this.xt(c):r?(a?c=this.Xt(t.invocationId,`${a}`,null):void 0!==s?c=this.Xt(t.invocationId,null,s):(this.u.log(e.Warning,`No result given for '${n}' method and invocation ID '${t.invocationId}'.`),c=this.Xt(t.invocationId,"Client didn't provide a result.",null)),await this.xt(c)):s&&this.u.log(e.Error,`Result given for '${n}' method but server is not expecting a result.`)}st(t){this.u.log(e.Debug,`HubConnection.connectionClosed(${t}) called while in state ${this.ut}.`),this.vt=this.vt||t||new r("The underlying connection was closed before the hub handshake could complete."),this.bt&&this.bt(),this.Jt(t||new Error("Invocation canceled due to the underlying connection being closed.")),this.Ct(),this.Tt(),this.ut===j.Disconnecting?this.Dt(t):this.ut===j.Connected&&this.Z?this.zt(t):this.ut===j.Connected&&this.Dt(t)}Dt(t){if(this.dt){this.ut=j.Disconnected,this.dt=!1,this.Pt&&(this.Pt.X(null!=t?t:new Error("Connection closed.")),this.Pt=void 0),g.isBrowser&&window.document.removeEventListener("freeze",this.G);try{this.rt.forEach((e=>e.apply(this,[t])))}catch(n){this.u.log(e.Error,`An onclose callback called with error '${t}' threw error '${n}'.`)}}}async zt(t){const n=Date.now();let i=0,o=void 0!==t?t:new Error("Attempting to reconnect due to a unknown error."),r=this.Vt(i,0,o);if(null===r)return this.u.log(e.Debug,"Connection not reconnecting because the IRetryPolicy returned null on the first reconnect attempt."),void this.Dt(t);if(this.ut=j.Reconnecting,t?this.u.log(e.Information,`Connection reconnecting because of error '${t}'.`):this.u.log(e.Information,"Connection reconnecting."),0!==this.ot.length){try{this.ot.forEach((e=>e.apply(this,[t])))}catch(n){this.u.log(e.Error,`An onreconnecting callback called with error '${t}' threw error '${n}'.`)}if(this.ut!==j.Reconnecting)return void this.u.log(e.Debug,"Connection left the reconnecting state in onreconnecting callback. Done reconnecting.")}for(;null!==r;){if(this.u.log(e.Information,`Reconnect attempt number ${i+1} will start in ${r} ms.`),await new Promise((t=>{this.Ht=setTimeout(t,r)})),this.Ht=void 0,this.ut!==j.Reconnecting)return void this.u.log(e.Debug,"Connection left the reconnecting state during reconnect delay. Done reconnecting.");try{if(await this.yt(),this.ut=j.Connected,this.u.log(e.Information,"HubConnection reconnected successfully."),0!==this.ht.length)try{this.ht.forEach((t=>t.apply(this,[this.connection.connectionId])))}catch(t){this.u.log(e.Error,`An onreconnected callback called with connectionId '${this.connection.connectionId}; threw error '${t}'.`)}return}catch(t){if(this.u.log(e.Information,`Reconnect attempt failed because of error '${t}'.`),this.ut!==j.Reconnecting)return this.u.log(e.Debug,`Connection moved to the '${this.ut}' from the reconnecting state during reconnect attempt. Done reconnecting.`),void(this.ut===j.Disconnecting&&this.Dt());i++,o=t instanceof Error?t:new Error(t.toString()),r=this.Vt(i,Date.now()-n,o)}}this.u.log(e.Information,`Reconnect retries have been exhausted after ${Date.now()-n} ms and ${i} failed attempts. Connection disconnecting.`),this.Dt()}Vt(t,n,i){try{return this.Z.nextRetryDelayInMilliseconds({elapsedMilliseconds:n,previousRetryCount:t,retryReason:i})}catch(i){return this.u.log(e.Error,`IRetryPolicy.nextRetryDelayInMilliseconds(${t}, ${n}) threw error '${i}'.`),null}}Jt(t){const n=this.it;this.it={},Object.keys(n).forEach((i=>{const o=n[i];try{o(null,t)}catch(n){this.u.log(e.Error,`Stream 'error' callback called with '${t}' threw error: ${T(n)}`)}}))}Tt(){this.Bt&&(clearTimeout(this.Bt),this.Bt=void 0)}Ct(){this.Ot&&clearTimeout(this.Ot)}Mt(t,e,n,i){if(n)return 0!==i.length?{target:t,arguments:e,streamIds:i,type:N.Invocation}:{target:t,arguments:e,type:N.Invocation};{const n=this.ct;return this.ct++,0!==i.length?{target:t,arguments:e,invocationId:n.toString(),streamIds:i,type:N.Invocation}:{target:t,arguments:e,invocationId:n.toString(),type:N.Invocation}}}qt(t,e){if(0!==t.length){e||(e=Promise.resolve());for(const n in t)t[n].subscribe({complete:()=>{e=e.then((()=>this.xt(this.Xt(n))))},error:t=>{let i;i=t instanceof Error?t.message:t&&t.toString?t.toString():"Unknown error",e=e.then((()=>this.xt(this.Xt(n,i))))},next:t=>{e=e.then((()=>this.xt(this.Kt(n,t))))}})}}Ut(t){const e=[],n=[];for(let i=0;i0)&&(e=!1,this.te=await this.Zt()),this.ee(t);const n=await this.Yt.send(t);return e&&401===n.statusCode&&this.Zt?(this.te=await this.Zt(),this.ee(t),await this.Yt.send(t)):n}ee(t){t.headers||(t.headers={}),this.te?t.headers[L.Authorization]=`Bearer ${this.te}`:this.Zt&&t.headers[L.Authorization]&&delete t.headers[L.Authorization]}getCookieString(t){return this.Yt.getCookieString(t)}}var U,B;!function(t){t[t.None=0]="None",t[t.WebSockets=1]="WebSockets",t[t.ServerSentEvents=2]="ServerSentEvents",t[t.LongPolling=4]="LongPolling"}(U||(U={})),function(t){t[t.Text=1]="Text",t[t.Binary=2]="Binary"}(B||(B={}));class W{constructor(){this.se=!1,this.onabort=null}abort(){this.se||(this.se=!0,this.onabort&&this.onabort())}get signal(){return this}get aborted(){return this.se}}class X{get pollAborted(){return this.ie.aborted}constructor(t,e,n){this.$=t,this.u=e,this.ie=new W,this.ne=n,this.re=!1,this.onreceive=null,this.onclose=null}async connect(t,n){if(m.isRequired(t,"url"),m.isRequired(n,"transferFormat"),m.isIn(n,B,"transferFormat"),this.oe=t,this.u.log(e.Trace,"(LongPolling transport) Connecting."),n===B.Binary&&"undefined"!=typeof XMLHttpRequest&&"string"!=typeof(new XMLHttpRequest).responseType)throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported.");const[o,r]=C(),s={[o]:r,...this.ne.headers},a={abortSignal:this.ie.signal,headers:s,timeout:1e5,withCredentials:this.ne.withCredentials};n===B.Binary&&(a.responseType="arraybuffer");const c=`${t}&_=${Date.now()}`;this.u.log(e.Trace,`(LongPolling transport) polling: ${c}.`);const l=await this.$.get(c,a);200!==l.statusCode?(this.u.log(e.Error,`(LongPolling transport) Unexpected response code: ${l.statusCode}.`),this.he=new i(l.statusText||"",l.statusCode),this.re=!1):this.re=!0,this.ce=this.ae(this.oe,a)}async ae(t,n){try{for(;this.re;)try{const o=`${t}&_=${Date.now()}`;this.u.log(e.Trace,`(LongPolling transport) polling: ${o}.`);const r=await this.$.get(o,n);204===r.statusCode?(this.u.log(e.Information,"(LongPolling transport) Poll terminated by server."),this.re=!1):200!==r.statusCode?(this.u.log(e.Error,`(LongPolling transport) Unexpected response code: ${r.statusCode}.`),this.he=new i(r.statusText||"",r.statusCode),this.re=!1):r.content?(this.u.log(e.Trace,`(LongPolling transport) data received. ${y(r.content,this.ne.logMessageContent)}.`),this.onreceive&&this.onreceive(r.content)):this.u.log(e.Trace,"(LongPolling transport) Poll timed out, reissuing.")}catch(t){this.re?t instanceof o?this.u.log(e.Trace,"(LongPolling transport) Poll timed out, reissuing."):(this.he=t,this.re=!1):this.u.log(e.Trace,`(LongPolling transport) Poll errored after shutdown: ${t.message}`)}}finally{this.u.log(e.Trace,"(LongPolling transport) Polling complete."),this.pollAborted||this.le()}}async send(t){return this.re?b(this.u,"LongPolling",this.$,this.oe,t,this.ne):Promise.reject(new Error("Cannot send until the transport is connected"))}async stop(){this.u.log(e.Trace,"(LongPolling transport) Stopping polling."),this.re=!1,this.ie.abort();try{await this.ce,this.u.log(e.Trace,`(LongPolling transport) sending DELETE request to ${this.oe}.`);const t={},[n,o]=C();t[n]=o;const r={headers:{...t,...this.ne.headers},timeout:this.ne.timeout,withCredentials:this.ne.withCredentials};let s;try{await this.$.delete(this.oe,r)}catch(t){s=t}s?s instanceof i&&(404===s.statusCode?this.u.log(e.Trace,"(LongPolling transport) A 404 response was returned from sending a DELETE request."):this.u.log(e.Trace,`(LongPolling transport) Error sending a DELETE request: ${s}`)):this.u.log(e.Trace,"(LongPolling transport) DELETE request accepted.")}finally{this.u.log(e.Trace,"(LongPolling transport) Stop finished."),this.le()}}le(){if(this.onclose){let t="(LongPolling transport) Firing onclose event.";this.he&&(t+=" Error: "+this.he),this.u.log(e.Trace,t),this.onclose(this.he)}}}class V{constructor(t,e,n,i){this.$=t,this.te=e,this.u=n,this.ne=i,this.onreceive=null,this.onclose=null}async connect(t,n){return m.isRequired(t,"url"),m.isRequired(n,"transferFormat"),m.isIn(n,B,"transferFormat"),this.u.log(e.Trace,"(SSE transport) Connecting."),this.oe=t,this.te&&(t+=(t.indexOf("?")<0?"?":"&")+`access_token=${encodeURIComponent(this.te)}`),new Promise(((i,o)=>{let r,s=!1;if(n===B.Text){if(g.isBrowser||g.isWebWorker)r=new this.ne.EventSource(t,{withCredentials:this.ne.withCredentials});else{const e=this.$.getCookieString(t),n={};n.Cookie=e;const[i,o]=C();n[i]=o,r=new this.ne.EventSource(t,{withCredentials:this.ne.withCredentials,headers:{...n,...this.ne.headers}})}try{r.onmessage=t=>{if(this.onreceive)try{this.u.log(e.Trace,`(SSE transport) data received. ${y(t.data,this.ne.logMessageContent)}.`),this.onreceive(t.data)}catch(t){return void this.ue(t)}},r.onerror=t=>{s?this.ue():o(new Error("EventSource failed to connect. The connection could not be found on the server, either the connection ID is not present on the server, or a proxy is refusing/buffering the connection. If you have multiple servers check that sticky sessions are enabled."))},r.onopen=()=>{this.u.log(e.Information,`SSE connected to ${this.oe}`),this.de=r,s=!0,i()}}catch(t){return void o(t)}}else o(new Error("The Server-Sent Events transport only supports the 'Text' transfer format"))}))}async send(t){return this.de?b(this.u,"SSE",this.$,this.oe,t,this.ne):Promise.reject(new Error("Cannot send until the transport is connected"))}stop(){return this.ue(),Promise.resolve()}ue(t){this.de&&(this.de.close(),this.de=void 0,this.onclose&&this.onclose(t))}}class J{constructor(t,e,n,i,o,r){this.u=n,this.Zt=e,this.fe=i,this.pe=o,this.$=t,this.onreceive=null,this.onclose=null,this.we=r}async connect(t,n){let i;return m.isRequired(t,"url"),m.isRequired(n,"transferFormat"),m.isIn(n,B,"transferFormat"),this.u.log(e.Trace,"(WebSockets transport) Connecting."),this.Zt&&(i=await this.Zt()),new Promise(((o,r)=>{let s;t=t.replace(/^http/,"ws");const a=this.$.getCookieString(t);let c=!1;if(g.isNode||g.isReactNative){const e={},[n,o]=C();e[n]=o,i&&(e[L.Authorization]=`Bearer ${i}`),a&&(e[L.Cookie]=a),s=new this.pe(t,void 0,{headers:{...e,...this.we}})}else i&&(t+=(t.indexOf("?")<0?"?":"&")+`access_token=${encodeURIComponent(i)}`);s||(s=new this.pe(t)),n===B.Binary&&(s.binaryType="arraybuffer"),s.onopen=n=>{this.u.log(e.Information,`WebSocket connected to ${t}.`),this.ge=s,c=!0,o()},s.onerror=t=>{let n=null;n="undefined"!=typeof ErrorEvent&&t instanceof ErrorEvent?t.error:"There was an error with the transport",this.u.log(e.Information,`(WebSockets transport) ${n}.`)},s.onmessage=t=>{if(this.u.log(e.Trace,`(WebSockets transport) data received. ${y(t.data,this.fe)}.`),this.onreceive)try{this.onreceive(t.data)}catch(t){return void this.ue(t)}},s.onclose=t=>{if(c)this.ue(t);else{let e=null;e="undefined"!=typeof ErrorEvent&&t instanceof ErrorEvent?t.error:"WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.",r(new Error(e))}}}))}send(t){return this.ge&&this.ge.readyState===this.pe.OPEN?(this.u.log(e.Trace,`(WebSockets transport) sending data. ${y(t,this.fe)}.`),this.ge.send(t),Promise.resolve()):Promise.reject("WebSocket is not in the OPEN state")}stop(){return this.ge&&this.ue(void 0),Promise.resolve()}ue(t){this.ge&&(this.ge.onclose=()=>{},this.ge.onmessage=()=>{},this.ge.onerror=()=>{},this.ge.close(),this.ge=void 0),this.u.log(e.Trace,"(WebSockets transport) socket closed."),this.onclose&&(!this.me(t)||!1!==t.wasClean&&1e3===t.code?t instanceof Error?this.onclose(t):this.onclose():this.onclose(new Error(`WebSocket closed with status code: ${t.code} (${t.reason||"no reason given"}).`)))}me(t){return t&&"boolean"==typeof t.wasClean&&"number"==typeof t.code}}class Z{constructor(t,n={}){var i;if(this.ye=()=>{},this.features={},this.ve=1,m.isRequired(t,"url"),this.u=void 0===(i=n.logger)?new w(e.Information):null===i?p.instance:void 0!==i.log?i:new w(i),this.baseUrl=this.be(t),(n=n||{}).logMessageContent=void 0!==n.logMessageContent&&n.logMessageContent,"boolean"!=typeof n.withCredentials&&void 0!==n.withCredentials)throw new Error("withCredentials option was not a 'boolean' or 'undefined' value");n.withCredentials=void 0===n.withCredentials||n.withCredentials,n.timeout=void 0===n.timeout?1e5:n.timeout;let o=null,r=null;if(g.isNode){const t=require;o=t("ws"),r=t("eventsource")}g.isNode||"undefined"==typeof WebSocket||n.WebSocket?g.isNode&&!n.WebSocket&&o&&(n.WebSocket=o):n.WebSocket=WebSocket,g.isNode||"undefined"==typeof EventSource||n.EventSource?g.isNode&&!n.EventSource&&void 0!==r&&(n.EventSource=r):n.EventSource=EventSource,this.$=new F(n.httpClient||new D(this.u),n.accessTokenFactory),this.ut="Disconnected",this.dt=!1,this.ne=n,this.onreceive=null,this.onclose=null}async start(t){if(t=t||B.Binary,m.isIn(t,B,"transferFormat"),this.u.log(e.Debug,`Starting connection with transfer format '${B[t]}'.`),"Disconnected"!==this.ut)return Promise.reject(new Error("Cannot start an HttpConnection that is not in the 'Disconnected' state."));if(this.ut="Connecting",this.Ee=this.yt(t),await this.Ee,"Disconnecting"===this.ut){const t="Failed to start the HttpConnection before stop() was called.";return this.u.log(e.Error,t),await this.It,Promise.reject(new r(t))}if("Connected"!==this.ut){const t="HttpConnection.startInternal completed gracefully but didn't enter the connection into the connected state!";return this.u.log(e.Error,t),Promise.reject(new r(t))}this.dt=!0}send(t){return"Connected"!==this.ut?Promise.reject(new Error("Cannot send data if the connection is not in the 'Connected' State.")):(this.$e||(this.$e=new Y(this.transport)),this.$e.send(t))}async stop(t){return"Disconnected"===this.ut?(this.u.log(e.Debug,`Call to HttpConnection.stop(${t}) ignored because the connection is already in the disconnected state.`),Promise.resolve()):"Disconnecting"===this.ut?(this.u.log(e.Debug,`Call to HttpConnection.stop(${t}) ignored because the connection is already in the disconnecting state.`),this.It):(this.ut="Disconnecting",this.It=new Promise((t=>{this.ye=t})),await this._t(t),void await this.It)}async _t(t){this.Ce=t;try{await this.Ee}catch(t){}if(this.transport){try{await this.transport.stop()}catch(t){this.u.log(e.Error,`HttpConnection.transport.stop() threw error '${t}'.`),this.Se()}this.transport=void 0}else this.u.log(e.Debug,"HttpConnection.transport is undefined in HttpConnection.stop() because start() failed.")}async yt(t){let n=this.baseUrl;this.Zt=this.ne.accessTokenFactory,this.$.Zt=this.Zt;try{if(this.ne.skipNegotiation){if(this.ne.transport!==U.WebSockets)throw new Error("Negotiation can only be skipped when using the WebSocket transport directly.");this.transport=this.ke(U.WebSockets),await this.Pe(n,t)}else{let e=null,i=0;do{if(e=await this.Te(n),"Disconnecting"===this.ut||"Disconnected"===this.ut)throw new r("The connection was stopped during negotiation.");if(e.error)throw new Error(e.error);if(e.ProtocolVersion)throw new Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");if(e.url&&(n=e.url),e.accessToken){const t=e.accessToken;this.Zt=()=>t,this.$.te=t,this.$.Zt=void 0}i++}while(e.url&&i<100);if(100===i&&e.url)throw new Error("Negotiate redirection limit exceeded.");await this.Ie(n,this.ne.transport,e,t)}this.transport instanceof X&&(this.features.inherentKeepAlive=!0),"Connecting"===this.ut&&(this.u.log(e.Debug,"The HttpConnection connected successfully."),this.ut="Connected")}catch(t){return this.u.log(e.Error,"Failed to start the connection: "+t),this.ut="Disconnected",this.transport=void 0,this.ye(),Promise.reject(t)}}async Te(t){const n={},[o,r]=C();n[o]=r;const s=this._e(t);this.u.log(e.Debug,`Sending negotiation request: ${s}.`);try{const t=await this.$.post(s,{content:"",headers:{...n,...this.ne.headers},timeout:this.ne.timeout,withCredentials:this.ne.withCredentials});if(200!==t.statusCode)return Promise.reject(new Error(`Unexpected status code returned from negotiate '${t.statusCode}'`));const e=JSON.parse(t.content);return(!e.negotiateVersion||e.negotiateVersion<1)&&(e.connectionToken=e.connectionId),e.useStatefulReconnect&&!0!==this.ne.He?Promise.reject(new l("Client didn't negotiate Stateful Reconnect but the server did.")):e}catch(t){let n="Failed to complete negotiation with the server: "+t;return t instanceof i&&404===t.statusCode&&(n+=" Either this is not a SignalR endpoint or there is a proxy blocking the connection."),this.u.log(e.Error,n),Promise.reject(new l(n))}}De(t,e){return e?t+(-1===t.indexOf("?")?"?":"&")+`id=${e}`:t}async Ie(t,n,i,o){let s=this.De(t,i.connectionToken);if(this.Re(n))return this.u.log(e.Debug,"Connection was provided an instance of ITransport, using that directly."),this.transport=n,await this.Pe(s,o),void(this.connectionId=i.connectionId);const a=[],l=i.availableTransports||[];let d=i;for(const i of l){const l=this.xe(i,n,o,!0===(null==d?void 0:d.useStatefulReconnect));if(l instanceof Error)a.push(`${i.transport} failed:`),a.push(l);else if(this.Re(l)){if(this.transport=l,!d){try{d=await this.Te(t)}catch(t){return Promise.reject(t)}s=this.De(t,d.connectionToken)}try{return await this.Pe(s,o),void(this.connectionId=d.connectionId)}catch(t){if(this.u.log(e.Error,`Failed to start the transport '${i.transport}': ${t}`),d=void 0,a.push(new c(`${i.transport} failed: ${t}`,U[i.transport])),"Connecting"!==this.ut){const t="Failed to select transport before stop() was called.";return this.u.log(e.Debug,t),Promise.reject(new r(t))}}}}return a.length>0?Promise.reject(new u(`Unable to connect to the server with any of the available transports. ${a.join(" ")}`,a)):Promise.reject(new Error("None of the transports supported by the client are supported by the server."))}ke(t){switch(t){case U.WebSockets:if(!this.ne.WebSocket)throw new Error("'WebSocket' is not supported in your environment.");return new J(this.$,this.Zt,this.u,this.ne.logMessageContent,this.ne.WebSocket,this.ne.headers||{});case U.ServerSentEvents:if(!this.ne.EventSource)throw new Error("'EventSource' is not supported in your environment.");return new V(this.$,this.$.te,this.u,this.ne);case U.LongPolling:return new X(this.$,this.u,this.ne);default:throw new Error(`Unknown transport: ${t}.`)}}Pe(t,e){return this.transport.onreceive=this.onreceive,this.features.reconnect?this.transport.onclose=async n=>{let i=!1;if(this.features.reconnect){try{this.features.disconnected(),await this.transport.connect(t,e),await this.features.resend()}catch{i=!0}i&&this.Se(n)}else this.Se(n)}:this.transport.onclose=t=>this.Se(t),this.transport.connect(t,e)}xe(t,n,i,o){const r=U[t.transport];if(null==r)return this.u.log(e.Debug,`Skipping transport '${t.transport}' because it is not supported by this client.`),new Error(`Skipping transport '${t.transport}' because it is not supported by this client.`);if(!function(t,e){return!t||!!(e&t)}(n,r))return this.u.log(e.Debug,`Skipping transport '${U[r]}' because it was disabled by the client.`),new a(`'${U[r]}' is disabled by the client.`,r);if(!(t.transferFormats.map((t=>B[t])).indexOf(i)>=0))return this.u.log(e.Debug,`Skipping transport '${U[r]}' because it does not support the requested transfer format '${B[i]}'.`),new Error(`'${U[r]}' does not support ${B[i]}.`);if(r===U.WebSockets&&!this.ne.WebSocket||r===U.ServerSentEvents&&!this.ne.EventSource)return this.u.log(e.Debug,`Skipping transport '${U[r]}' because it is not supported in your environment.'`),new s(`'${U[r]}' is not supported in your environment.`,r);this.u.log(e.Debug,`Selecting transport '${U[r]}'.`);try{return this.features.reconnect=r===U.WebSockets?o:void 0,this.ke(r)}catch(t){return t}}Re(t){return t&&"object"==typeof t&&"connect"in t}Se(t){if(this.u.log(e.Debug,`HttpConnection.stopConnection(${t}) called while in state ${this.ut}.`),this.transport=void 0,t=this.Ce||t,this.Ce=void 0,"Disconnected"!==this.ut){if("Connecting"===this.ut)throw this.u.log(e.Warning,`Call to HttpConnection.stopConnection(${t}) was ignored because the connection is still in the connecting state.`),new Error(`HttpConnection.stopConnection(${t}) was called while the connection is still in the connecting state.`);if("Disconnecting"===this.ut&&this.ye(),t?this.u.log(e.Error,`Connection disconnected with error '${t}'.`):this.u.log(e.Information,"Connection disconnected."),this.$e&&(this.$e.stop().catch((t=>{this.u.log(e.Error,`TransportSendQueue.stop() threw error '${t}'.`)})),this.$e=void 0),this.connectionId=void 0,this.ut="Disconnected",this.dt){this.dt=!1;try{this.onclose&&this.onclose(t)}catch(n){this.u.log(e.Error,`HttpConnection.onclose(${t}) threw error '${n}'.`)}}}else this.u.log(e.Debug,`Call to HttpConnection.stopConnection(${t}) was ignored because the connection is already in the disconnected state.`)}be(t){if(0===t.lastIndexOf("https://",0)||0===t.lastIndexOf("http://",0))return t;if(!g.isBrowser)throw new Error(`Cannot resolve '${t}'.`);const n=window.document.createElement("a");return n.href=t,this.u.log(e.Information,`Normalizing '${t}' to '${n.href}'.`),n.href}_e(t){const e=new URL(t);e.pathname.endsWith("/")?e.pathname+="negotiate":e.pathname+="/negotiate";const n=new URLSearchParams(e.searchParams);return n.has("negotiateVersion")||n.append("negotiateVersion",this.ve.toString()),n.has("useStatefulReconnect")?"true"===n.get("useStatefulReconnect")&&(this.ne.He=!0):!0===this.ne.He&&n.append("useStatefulReconnect","true"),e.search=n.toString(),e.toString()}}class Y{constructor(t){this.Ae=t,this.Ue=[],this.Le=!0,this.Ne=new Q,this.qe=new Q,this.Me=this.je()}send(t){return this.We(t),this.qe||(this.qe=new Q),this.qe.promise}stop(){return this.Le=!1,this.Ne.resolve(),this.Me}We(t){if(this.Ue.length&&typeof this.Ue[0]!=typeof t)throw new Error(`Expected data to be of type ${typeof this.Ue} but was of type ${typeof t}`);this.Ue.push(t),this.Ne.resolve()}async je(){for(;;){if(await this.Ne.promise,!this.Le){this.qe&&this.qe.reject("Connection stopped.");break}this.Ne=new Q;const t=this.qe;this.qe=void 0;const e="string"==typeof this.Ue[0]?this.Ue.join(""):Y.Oe(this.Ue);this.Ue.length=0;try{await this.Ae.send(e),t.resolve()}catch(e){t.reject(e)}}}static Oe(t){const e=t.map((t=>t.byteLength)).reduce(((t,e)=>t+e)),n=new Uint8Array(e);let i=0;for(const e of t)n.set(new Uint8Array(e),i),i+=e.byteLength;return n.buffer}}class Q{constructor(){this.promise=new Promise(((t,e)=>[this.j,this.Fe]=[t,e]))}resolve(){this.j()}reject(t){this.Fe(t)}}class K{constructor(){this.name="json",this.version=2,this.transferFormat=B.Text}parseMessages(t,n){if("string"!=typeof t)throw new Error("Invalid input for JSON hub protocol. Expected a string.");if(!t)return[];null===n&&(n=p.instance);const i=A.parse(t),o=[];for(const t of i){const i=JSON.parse(t);if("number"!=typeof i.type)throw new Error("Invalid payload.");switch(i.type){case N.Invocation:this.U(i);break;case N.StreamItem:this.Be(i);break;case N.Completion:this.Xe(i);break;case N.Ping:case N.Close:break;case N.Ack:this.Je(i);break;case N.Sequence:this.ze(i);break;default:n.log(e.Information,"Unknown message type '"+i.type+"' ignored.");continue}o.push(i)}return o}writeMessage(t){return A.write(JSON.stringify(t))}U(t){this.Ve(t.target,"Invalid payload for Invocation message."),void 0!==t.invocationId&&this.Ve(t.invocationId,"Invalid payload for Invocation message.")}Be(t){if(this.Ve(t.invocationId,"Invalid payload for StreamItem message."),void 0===t.item)throw new Error("Invalid payload for StreamItem message.")}Xe(t){if(t.result&&t.error)throw new Error("Invalid payload for Completion message.");!t.result&&t.error&&this.Ve(t.error,"Invalid payload for Completion message."),this.Ve(t.invocationId,"Invalid payload for Completion message.")}Je(t){if("number"!=typeof t.sequenceId)throw new Error("Invalid SequenceId for Ack message.")}ze(t){if("number"!=typeof t.sequenceId)throw new Error("Invalid SequenceId for Sequence message.")}Ve(t,e){if("string"!=typeof t||""===t)throw new Error(e)}}const G={trace:e.Trace,debug:e.Debug,info:e.Information,information:e.Information,warn:e.Warning,warning:e.Warning,error:e.Error,critical:e.Critical,none:e.None};class tt{configureLogging(t){if(m.isRequired(t,"logging"),void 0!==t.log)this.logger=t;else if("string"==typeof t){const e=function(t){const e=G[t.toLowerCase()];if(void 0!==e)return e;throw new Error(`Unknown log level: ${t}`)}(t);this.logger=new w(e)}else this.logger=new w(t);return this}withUrl(t,e){return m.isRequired(t,"url"),m.isNotEmpty(t,"url"),this.url=t,this.httpConnectionOptions="object"==typeof e?{...this.httpConnectionOptions,...e}:{...this.httpConnectionOptions,transport:e},this}withHubProtocol(t){return m.isRequired(t,"protocol"),this.protocol=t,this}withAutomaticReconnect(t){if(this.reconnectPolicy)throw new Error("A reconnectPolicy has already been set.");return t?Array.isArray(t)?this.reconnectPolicy=new z(t):this.reconnectPolicy=t:this.reconnectPolicy=new z,this}withServerTimeout(t){return m.isRequired(t,"milliseconds"),this.Ke=t,this}withKeepAliveInterval(t){return m.isRequired(t,"milliseconds"),this.Ge=t,this}withStatefulReconnect(t){return void 0===this.httpConnectionOptions&&(this.httpConnectionOptions={}),this.httpConnectionOptions.He=!0,this.Y=null==t?void 0:t.bufferSize,this}build(){const t=this.httpConnectionOptions||{};if(void 0===t.logger&&(t.logger=this.logger),!this.url)throw new Error("The 'HubConnectionBuilder.withUrl' method must be called before building the connection.");const e=new Z(this.url,t);return O.create(e,this.logger||p.instance,this.protocol||new K,this.reconnectPolicy,this.Ke,this.Ge,this.Y)}}return Uint8Array.prototype.indexOf||Object.defineProperty(Uint8Array.prototype,"indexOf",{value:Array.prototype.indexOf,writable:!0}),Uint8Array.prototype.slice||Object.defineProperty(Uint8Array.prototype,"slice",{value:function(t,e){return new Uint8Array(Array.prototype.slice.call(this,t,e))},writable:!0}),Uint8Array.prototype.forEach||Object.defineProperty(Uint8Array.prototype,"forEach",{value:Array.prototype.forEach,writable:!0}),n})(),"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.signalR=e():t.signalR=e(),$.extend($t,{t1:"Eingabe erforderlich",t2:"Bitte überprüfen Sie Ihre Eingaben im Formular.",b0:"Erstellt",b1:"Zuletzt geändert",b2:"von",t12:"Der Server hat einen Fehler zurückgegeben. Bitte versuchen Sie es erneut.",t17:"Eine Email mit einem Aktivierungs-Link wurde an deine Adresse versandt.",t18:"Ein Account mit deinem Namen existiert bereits. Dennoch erstellen?",t19:"Einträge sind entweder unngültig oder zu kurz.",t20:"Der Server hat einen Fehler gemeldet. Bitte versuch es erneut.",t21:"Der Zugang wurde nicht gefunden.",t30a:"Als erledigt markieren.",t30b:"Als unerledigt markieren.",t55:"Ein Email mit einem Aktivierungs-Link wurde an Ihre Adresse versandt.",t56:"Ein Zugang für diesen Namen besteht bereits. Trotzdem erstellen?",t57:"Ein bestehender Zugang wurde für diese Serie registriert.",t60:"Bitte geben Sie Email-Adresse an, die Sie hier hinterlegt haben.",t61:"Ihr Passwort wurde erfolgreich versandt.",t62:"Die angegebene Email-Adresse stimmt nicht mit der hier hinterlegten überein.",ov:"Persönliche Übersicht"});var $v={}; +var t,e,$t={lng:"de-DE",dn:["So","Mo","Di","Mi","Do","Fr","Sa"],mn:["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],ma:["Jan","Feb","Mrz","Apr","Mai","Jun","Jul","Aug","Sep","Okt","Nov","Dez"],datepattern:"(0[1-9]|1[0-9]|2[0-9]|3[01]).(0[1-9]|1[012]).[0-9]{4}",datetimepattern:"(0[1-9]|1[0-9]|2[0-9]|3[01]).(0[1-9]|1[012]).[0-9]{4}\\s([0-5][0-9]):([0-5][0-9])",dateplaceholder:"dd.MM.yyyy",datetimeplaceholder:"dd.MM.yyyy HH:mm",dateformat:"dd.MM.yyyy",datetimeformat:"dd.MM.yyyy HH:mm",f1:"Der Server hat einen Fehler gemeldet: \n",f2:"Bitte versuchen Sie es erneut.",m0:"Diese Internet-Seite benötigt einen html5-kompatiblen Browser.",m0b:"Unterstützt werden bspw: Internet Explorer ab Version 10, Firefox ab Version 31, Chrome ab Version 31, Safari ab Version 7, Opera ab Version 27",m1:"Dieser Datensatz ist momentan von jemand anderem zur Bearbeitung gesperrt.",m2:"Diese Funktion ist zur Zeit nicht verfügbar",t1:"Eingabe erforderlich.",t2:"Eingabe ist nicht erforderlich.",true:"Ja",false:"Nein",alert:"Hinweis",confirm:"Bestätigen",open:"Öffnen","not implemented":"Diese Funktion in zur Zeit noch nicht verfügbar.",l0:"Anmeldung",l1:"Email / Anmeldename",l2:"Email-Adresse / Anmeldename",l3:"Passwort",l4:"Benutzer",l5:"Wird vom System ermittelt...",l6:"Anmelden",l7:"Passwort vergessen?",l7a:'Die "Passwort vergessen"-Funktion läuft in zwei Schritten ab:\n \nIm ersten Schritt wird eine SMS mit einem Code an die hinterlegte Mobilfunk-Nummer versandt.\nIm zweiten Schritt geben Sie bitte diesen Code in das Formular ein und übermitteln es erneut.\n \nIn beiden Schritten wird aus Sicherheitsgründen kein Fehler angezeigt und auch dann ein erfolgreicher Versand bestätigt, wenn die Kombination aus Email-Adresse und Nachname nicht gefunden wurde und/oder der code falsch ist.',l8:"Keinen Account?",l9:"Anmeldenamen der Email-Adresse wurde nicht erkannt.",l10:"Nachname",l11:"Email-Adresse",l12:"Passwort zusenden",l13:"Das Passwort wurde erfolgreich verschickt",l14:"Das Passwort konnte nicht verschickt werden",l15:"Sie sind nicht berechtigt, diese Funktion auszuführen.",l16:"Sie müssen zunächst einen Account angeben.",l17:"Die Kombination aus Anmeldenamen und Passwort konnte nicht bestätigt werden.",l18:"Es gibt ein Problem mit dem Formular.\nEs kann momentan nicht verarbeitet und versendet werden.",name:"Name",submit:"Senden",cancel:"Abbrechen",noop:"Diese Funktion is noch nicht verfügar."};t=self,e=()=>(()=>{var t={d:(e,n)=>{for(var i in n)t.o(n,i)&&!t.o(e,i)&&Object.defineProperty(e,i,{enumerable:!0,get:n[i]})}};t.g=function(){if("object"==typeof globalThis)return globalThis;try{return this||new Function("return this")()}catch(t){if("object"==typeof window)return window}}(),t.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),t.r=t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"t",{value:!0})};var e,n={};t.r(n),t.d(n,{AbortError:()=>r,DefaultHttpClient:()=>D,HttpClient:()=>h,HttpError:()=>i,HttpResponse:()=>d,HttpTransportType:()=>U,HubConnection:()=>O,HubConnectionBuilder:()=>tt,HubConnectionState:()=>j,JsonHubProtocol:()=>K,LogLevel:()=>e,MessageType:()=>N,NullLogger:()=>p,Subject:()=>q,TimeoutError:()=>o,TransferFormat:()=>B,VERSION:()=>f});class i extends Error{constructor(t,e){const n=new.target.prototype;super(`${t}: Status code '${e}'`),this.statusCode=e,this.__proto__=n}}class o extends Error{constructor(t="A timeout occurred."){const e=new.target.prototype;super(t),this.__proto__=e}}class r extends Error{constructor(t="An abort occurred."){const e=new.target.prototype;super(t),this.__proto__=e}}class s extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.transport=e,this.errorType="UnsupportedTransportError",this.__proto__=n}}class a extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.transport=e,this.errorType="DisabledTransportError",this.__proto__=n}}class c extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.transport=e,this.errorType="FailedToStartTransportError",this.__proto__=n}}class l extends Error{constructor(t){const e=new.target.prototype;super(t),this.errorType="FailedToNegotiateWithServerError",this.__proto__=e}}class u extends Error{constructor(t,e){const n=new.target.prototype;super(t),this.innerErrors=e,this.__proto__=n}}class d{constructor(t,e,n){this.statusCode=t,this.statusText=e,this.content=n}}class h{get(t,e){return this.send({...e,method:"GET",url:t})}post(t,e){return this.send({...e,method:"POST",url:t})}delete(t,e){return this.send({...e,method:"DELETE",url:t})}getCookieString(t){return""}}!function(t){t[t.Trace=0]="Trace",t[t.Debug=1]="Debug",t[t.Information=2]="Information",t[t.Warning=3]="Warning",t[t.Error=4]="Error",t[t.Critical=5]="Critical",t[t.None=6]="None"}(e||(e={}));class p{constructor(){}log(t,e){}}p.instance=new p;const f="10.0.0";class m{static isRequired(t,e){if(null==t)throw new Error(`The '${e}' argument is required.`)}static isNotEmpty(t,e){if(!t||t.match(/^\s*$/))throw new Error(`The '${e}' argument should not be empty.`)}static isIn(t,e,n){if(!(t in e))throw new Error(`Unknown ${n} value: ${t}.`)}}class g{static get isBrowser(){return!g.isNode&&"object"==typeof window&&"object"==typeof window.document}static get isWebWorker(){return!g.isNode&&"object"==typeof self&&"importScripts"in self}static get isReactNative(){return!g.isNode&&"object"==typeof window&&void 0===window.document}static get isNode(){return"undefined"!=typeof process&&process.release&&"node"===process.release.name}}function y(t,e){let n="";return v(t)?(n=`Binary data of length ${t.byteLength}`,e&&(n+=`. Content: '${function(t){const e=new Uint8Array(t);let n="";return e.forEach((t=>{n+=`0x${t<16?"0":""}${t.toString(16)} `})),n.substring(0,n.length-1)}(t)}'`)):"string"==typeof t&&(n=`String data of length ${t.length}`,e&&(n+=`. Content: '${t}'`)),n}function v(t){return t&&"undefined"!=typeof ArrayBuffer&&(t instanceof ArrayBuffer||t.constructor&&"ArrayBuffer"===t.constructor.name)}async function b(t,n,i,o,r,s){const a={},[c,l]=C();a[c]=l,t.log(e.Trace,`(${n} transport) sending data. ${y(r,s.logMessageContent)}.`);const u=v(r)?"arraybuffer":"text",d=await i.post(o,{content:r,headers:{...a,...s.headers},responseType:u,timeout:s.timeout,withCredentials:s.withCredentials});t.log(e.Trace,`(${n} transport) request complete. Response status: ${d.statusCode}.`)}class ${constructor(t,e){this.i=t,this.h=e}dispose(){const t=this.i.observers.indexOf(this.h);t>-1&&this.i.observers.splice(t,1),0===this.i.observers.length&&this.i.cancelCallback&&this.i.cancelCallback().catch((t=>{}))}}class w{constructor(t){this.l=t,this.out=console}log(t,n){if(t>=this.l){const i=`[${(new Date).toISOString()}] ${e[t]}: ${n}`;switch(t){case e.Critical:case e.Error:this.out.error(i);break;case e.Warning:this.out.warn(i);break;case e.Information:this.out.info(i);break;default:this.out.log(i)}}}}function C(){let t="X-SignalR-User-Agent";return g.isNode&&(t="User-Agent"),[t,S(f,_(),g.isNode?"NodeJS":"Browser",x())]}function S(t,e,n,i){let o="Microsoft SignalR/";const r=t.split(".");return o+=`${r[0]}.${r[1]}`,o+=` (${t}; `,o+=e&&""!==e?`${e}; `:"Unknown OS; ",o+=`${n}`,o+=i?`; ${i}`:"; Unknown Runtime Version",o+=")",o}function _(){if(!g.isNode)return"";switch(process.platform){case"win32":return"Windows NT";case"darwin":return"macOS";case"linux":return"Linux";default:return process.platform}}function x(){if(g.isNode)return process.versions.node}function T(t){return t.stack?t.stack:t.message?t.message:`${t}`}class E extends h{constructor(e){if(super(),this.u=e,"undefined"==typeof fetch||g.isNode){const t=require;this.p=new(t("tough-cookie").CookieJar),"undefined"==typeof fetch?this.m=t("node-fetch"):this.m=fetch,this.m=t("fetch-cookie")(this.m,this.p)}else this.m=fetch.bind(function(){if("undefined"!=typeof globalThis)return globalThis;if("undefined"!=typeof self)return self;if("undefined"!=typeof window)return window;if(void 0!==t.g)return t.g;throw new Error("could not find global")}());if("undefined"==typeof AbortController){const t=require;this.v=t("abort-controller")}else this.v=AbortController}async send(t){if(t.abortSignal&&t.abortSignal.aborted)throw new r;if(!t.method)throw new Error("No method defined.");if(!t.url)throw new Error("No url defined.");const n=new this.v;let s;t.abortSignal&&(t.abortSignal.onabort=()=>{n.abort(),s=new r});let a,c=null;if(t.timeout){const i=t.timeout;c=setTimeout((()=>{n.abort(),this.u.log(e.Warning,"Timeout from HTTP request."),s=new o}),i)}""===t.content&&(t.content=void 0),t.content&&(t.headers=t.headers||{},v(t.content)?t.headers["Content-Type"]="application/octet-stream":t.headers["Content-Type"]="text/plain;charset=UTF-8");try{a=await this.m(t.url,{body:t.content,cache:"no-cache",credentials:!0===t.withCredentials?"include":"same-origin",headers:{"X-Requested-With":"XMLHttpRequest",...t.headers},method:t.method,mode:"cors",redirect:"follow",signal:n.signal})}catch(t){if(s)throw s;throw this.u.log(e.Warning,`Error from HTTP request. ${t}.`),t}finally{c&&clearTimeout(c),t.abortSignal&&(t.abortSignal.onabort=null)}if(!a.ok){const t=await k(a,"text");throw new i(t||a.statusText,a.status)}const l=k(a,t.responseType),u=await l;return new d(a.status,a.statusText,u)}getCookieString(t){let e="";return g.isNode&&this.p&&this.p.getCookies(t,((t,n)=>e=n.join("; "))),e}}function k(t,e){let n;switch(e){case"arraybuffer":n=t.arrayBuffer();break;case"text":default:n=t.text();break;case"blob":case"document":case"json":throw new Error(`${e} is not supported.`)}return n}class I extends h{constructor(t){super(),this.u=t}send(t){return t.abortSignal&&t.abortSignal.aborted?Promise.reject(new r):t.method?t.url?new Promise(((n,s)=>{const a=new XMLHttpRequest;a.open(t.method,t.url,!0),a.withCredentials=void 0===t.withCredentials||t.withCredentials,a.setRequestHeader("X-Requested-With","XMLHttpRequest"),""===t.content&&(t.content=void 0),t.content&&(v(t.content)?a.setRequestHeader("Content-Type","application/octet-stream"):a.setRequestHeader("Content-Type","text/plain;charset=UTF-8"));const c=t.headers;c&&Object.keys(c).forEach((t=>{a.setRequestHeader(t,c[t])})),t.responseType&&(a.responseType=t.responseType),t.abortSignal&&(t.abortSignal.onabort=()=>{a.abort(),s(new r)}),t.timeout&&(a.timeout=t.timeout),a.onload=()=>{t.abortSignal&&(t.abortSignal.onabort=null),a.status>=200&&a.status<300?n(new d(a.status,a.statusText,a.response||a.responseText)):s(new i(a.response||a.responseText||a.statusText,a.status))},a.onerror=()=>{this.u.log(e.Warning,`Error from HTTP request. ${a.status}: ${a.statusText}.`),s(new i(a.statusText,a.status))},a.ontimeout=()=>{this.u.log(e.Warning,"Timeout from HTTP request."),s(new o)},a.send(t.content)})):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}}class D extends h{constructor(t){if(super(),"undefined"!=typeof fetch||g.isNode)this.$=new E(t);else{if("undefined"==typeof XMLHttpRequest)throw new Error("No usable HttpClient found.");this.$=new I(t)}}send(t){return t.abortSignal&&t.abortSignal.aborted?Promise.reject(new r):t.method?t.url?this.$.send(t):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}getCookieString(t){return this.$.getCookieString(t)}}class A{static write(t){return`${t}${A.RecordSeparator}`}static parse(t){if(t[t.length-1]!==A.RecordSeparator)throw new Error("Message is incomplete.");const e=t.split(A.RecordSeparator);return e.pop(),e}}A.RecordSeparatorCode=30,A.RecordSeparator=String.fromCharCode(A.RecordSeparatorCode);class P{writeHandshakeRequest(t){return A.write(JSON.stringify(t))}parseHandshakeResponse(t){let e,n;if(v(t)){const i=new Uint8Array(t),o=i.indexOf(A.RecordSeparatorCode);if(-1===o)throw new Error("Message is incomplete.");const r=o+1;e=String.fromCharCode.apply(null,Array.prototype.slice.call(i.slice(0,r))),n=i.byteLength>r?i.slice(r).buffer:null}else{const i=t,o=i.indexOf(A.RecordSeparator);if(-1===o)throw new Error("Message is incomplete.");const r=o+1;e=i.substring(0,r),n=i.length>r?i.substring(r):null}const i=A.parse(e),o=JSON.parse(i[0]);if(o.type)throw new Error("Expected a handshake response from the server.");return[n,o]}}var N,j;!function(t){t[t.Invocation=1]="Invocation",t[t.StreamItem=2]="StreamItem",t[t.Completion=3]="Completion",t[t.StreamInvocation=4]="StreamInvocation",t[t.CancelInvocation=5]="CancelInvocation",t[t.Ping=6]="Ping",t[t.Close=7]="Close",t[t.Ack=8]="Ack",t[t.Sequence=9]="Sequence"}(N||(N={}));class q{constructor(){this.observers=[]}next(t){for(const e of this.observers)e.next(t)}error(t){for(const e of this.observers)e.error&&e.error(t)}complete(){for(const t of this.observers)t.complete&&t.complete()}subscribe(t){return this.observers.push(t),new $(this,t)}}class M{constructor(t,e,n){this.C=1e5,this.S=[],this.k=0,this.P=!1,this.T=1,this.I=0,this._=0,this.H=!1,this.D=t,this.R=e,this.C=n}async A(t){const e=this.D.writeMessage(t);let n=Promise.resolve();if(this.U(t)){this.k++;let t=()=>{},i=()=>{};v(e)?this._+=e.byteLength:this._+=e.length,this._>=this.C&&(n=new Promise(((e,n)=>{t=e,i=n}))),this.S.push(new R(e,this.k,t,i))}try{this.H||await this.R.send(e)}catch{this.L()}await n}N(t){let e=-1;for(let n=0;nthis.T?this.R.stop(new Error("Sequence ID greater than amount of messages we've received.")):this.T=t.sequenceId}L(){this.H=!0,this.P=!0}async B(){const t=0!==this.S.length?this.S[0].q:this.k+1;await this.R.send(this.D.writeMessage({type:N.Sequence,sequenceId:t}));const e=this.S;for(const t of e)await this.R.send(t.M);this.H=!1}X(t){null!=t||(t=new Error("Unable to reconnect to server."));for(const e of this.S)e.J(t)}U(t){switch(t.type){case N.Invocation:case N.StreamItem:case N.Completion:case N.StreamInvocation:case N.CancelInvocation:return!0;case N.Close:case N.Sequence:case N.Ping:case N.Ack:return!1}}O(){void 0===this.V&&(this.V=setTimeout((async()=>{try{this.H||await this.R.send(this.D.writeMessage({type:N.Ack,sequenceId:this.I}))}catch{}clearTimeout(this.V),this.V=void 0}),1e3))}}class R{constructor(t,e,n,i){this.M=t,this.q=e,this.j=n,this.J=i}}!function(t){t.Disconnected="Disconnected",t.Connecting="Connecting",t.Connected="Connected",t.Disconnecting="Disconnecting",t.Reconnecting="Reconnecting"}(j||(j={}));class O{static create(t,e,n,i,o,r,s){return new O(t,e,n,i,o,r,s)}constructor(t,n,i,o,r,s,a){this.K=0,this.G=()=>{this.u.log(e.Warning,"The page is being frozen, this will likely lead to the connection being closed and messages being lost. For more information see the docs at https://learn.microsoft.com/aspnet/core/signalr/javascript-client#bsleep")},m.isRequired(t,"connection"),m.isRequired(n,"logger"),m.isRequired(i,"protocol"),this.serverTimeoutInMilliseconds=null!=r?r:3e4,this.keepAliveIntervalInMilliseconds=null!=s?s:15e3,this.Y=null!=a?a:1e5,this.u=n,this.D=i,this.connection=t,this.Z=o,this.tt=new P,this.connection.onreceive=t=>this.et(t),this.connection.onclose=t=>this.st(t),this.it={},this.nt={},this.rt=[],this.ot=[],this.ht=[],this.ct=0,this.lt=!1,this.ut=j.Disconnected,this.dt=!1,this.ft=this.D.writeMessage({type:N.Ping})}get state(){return this.ut}get connectionId(){return this.connection&&this.connection.connectionId||null}get baseUrl(){return this.connection.baseUrl||""}set baseUrl(t){if(this.ut!==j.Disconnected&&this.ut!==j.Reconnecting)throw new Error("The HubConnection must be in the Disconnected or Reconnecting state to change the url.");if(!t)throw new Error("The HubConnection url must be a valid url.");this.connection.baseUrl=t}start(){return this.wt=this.gt(),this.wt}async gt(){if(this.ut!==j.Disconnected)return Promise.reject(new Error("Cannot start a HubConnection that is not in the 'Disconnected' state."));this.ut=j.Connecting,this.u.log(e.Debug,"Starting HubConnection.");try{await this.yt(),g.isBrowser&&window.document.addEventListener("freeze",this.G),this.ut=j.Connected,this.dt=!0,this.u.log(e.Debug,"HubConnection connected successfully.")}catch(t){return this.ut=j.Disconnected,this.u.log(e.Debug,`HubConnection failed to start successfully because of error '${t}'.`),Promise.reject(t)}}async yt(){this.vt=void 0,this.lt=!1;const t=new Promise(((t,e)=>{this.bt=t,this.Et=e}));await this.connection.start(this.D.transferFormat);try{let n=this.D.version;this.connection.features.reconnect||(n=1);const i={protocol:this.D.name,version:n};if(this.u.log(e.Debug,"Sending handshake request."),await this.$t(this.tt.writeHandshakeRequest(i)),this.u.log(e.Information,`Using HubProtocol '${this.D.name}'.`),this.Ct(),this.St(),this.kt(),await t,this.vt)throw this.vt;!!this.connection.features.reconnect&&(this.Pt=new M(this.D,this.connection,this.Y),this.connection.features.disconnected=this.Pt.L.bind(this.Pt),this.connection.features.resend=()=>{if(this.Pt)return this.Pt.B()}),this.connection.features.inherentKeepAlive||await this.$t(this.ft)}catch(t){throw this.u.log(e.Debug,`Hub handshake failed with error '${t}' during start(). Stopping HubConnection.`),this.Ct(),this.Tt(),await this.connection.stop(t),t}}async stop(){const t=this.wt;this.connection.features.reconnect=!1,this.It=this._t(),await this.It;try{await t}catch(t){}}_t(t){if(this.ut===j.Disconnected)return this.u.log(e.Debug,`Call to HubConnection.stop(${t}) ignored because it is already in the disconnected state.`),Promise.resolve();if(this.ut===j.Disconnecting)return this.u.log(e.Debug,`Call to HttpConnection.stop(${t}) ignored because the connection is already in the disconnecting state.`),this.It;const n=this.ut;return this.ut=j.Disconnecting,this.u.log(e.Debug,"Stopping HubConnection."),this.Ht?(this.u.log(e.Debug,"Connection stopped during reconnect delay. Done reconnecting."),clearTimeout(this.Ht),this.Ht=void 0,this.Dt(),Promise.resolve()):(n===j.Connected&&this.Rt(),this.Ct(),this.Tt(),this.vt=t||new r("The connection was stopped before the hub handshake could complete."),this.connection.stop(t))}async Rt(){try{await this.xt(this.At())}catch{}}stream(t,...e){const[n,i]=this.Ut(e),o=this.Lt(t,e,i);let r;const s=new q;return s.cancelCallback=()=>{const t=this.Nt(o.invocationId);return delete this.it[o.invocationId],r.then((()=>this.xt(t)))},this.it[o.invocationId]=(t,e)=>{e?s.error(e):t&&(t.type===N.Completion?t.error?s.error(new Error(t.error)):s.complete():s.next(t.item))},r=this.xt(o).catch((t=>{s.error(t),delete this.it[o.invocationId]})),this.qt(n,r),s}$t(t){return this.kt(),this.connection.send(t)}xt(t){return this.Pt?this.Pt.A(t):this.$t(this.D.writeMessage(t))}send(t,...e){const[n,i]=this.Ut(e),o=this.xt(this.Mt(t,e,!0,i));return this.qt(n,o),o}invoke(t,...e){const[n,i]=this.Ut(e),o=this.Mt(t,e,!1,i);return new Promise(((t,e)=>{this.it[o.invocationId]=(n,i)=>{i?e(i):n&&(n.type===N.Completion?n.error?e(new Error(n.error)):t(n.result):e(new Error(`Unexpected message type: ${n.type}`)))};const i=this.xt(o).catch((t=>{e(t),delete this.it[o.invocationId]}));this.qt(n,i)}))}on(t,e){t&&e&&(t=t.toLowerCase(),this.nt[t]||(this.nt[t]=[]),-1===this.nt[t].indexOf(e)&&this.nt[t].push(e))}off(t,e){if(!t)return;t=t.toLowerCase();const n=this.nt[t];if(n)if(e){const i=n.indexOf(e);-1!==i&&(n.splice(i,1),0===n.length&&delete this.nt[t])}else delete this.nt[t]}onclose(t){t&&this.rt.push(t)}onreconnecting(t){t&&this.ot.push(t)}onreconnected(t){t&&this.ht.push(t)}et(t){if(this.Ct(),this.lt||(t=this.jt(t),this.lt=!0),t){const n=this.D.parseMessages(t,this.u);for(const i of n)if(!this.Pt||this.Pt.W(i))switch(i.type){case N.Invocation:this.Wt(i).catch((t=>{this.u.log(e.Error,`Invoke client method threw error: ${T(t)}`)}));break;case N.StreamItem:case N.Completion:{const n=this.it[i.invocationId];if(n){i.type===N.Completion&&delete this.it[i.invocationId];try{n(i)}catch(t){this.u.log(e.Error,`Stream callback threw error: ${T(t)}`)}}break}case N.Ping:break;case N.Close:{this.u.log(e.Information,"Close message received from server.");const t=i.error?new Error("Server returned an error on close: "+i.error):void 0;!0===i.allowReconnect?this.connection.stop(t):this.It=this._t(t);break}case N.Ack:this.Pt&&this.Pt.N(i);break;case N.Sequence:this.Pt&&this.Pt.F(i);break;default:this.u.log(e.Warning,`Invalid message type: ${i.type}.`)}}this.St()}jt(t){let n,i;try{[i,n]=this.tt.parseHandshakeResponse(t)}catch(t){const n="Error parsing handshake response: "+t;this.u.log(e.Error,n);const i=new Error(n);throw this.Et(i),i}if(n.error){const t="Server returned handshake error: "+n.error;this.u.log(e.Error,t);const i=new Error(t);throw this.Et(i),i}return this.u.log(e.Debug,"Server handshake complete."),this.bt(),i}kt(){this.connection.features.inherentKeepAlive||(this.K=(new Date).getTime()+this.keepAliveIntervalInMilliseconds,this.Tt())}St(){if(!this.connection.features||!this.connection.features.inherentKeepAlive){this.Ot=setTimeout((()=>this.serverTimeout()),this.serverTimeoutInMilliseconds);let t=this.K-(new Date).getTime();if(t<0)return void(this.ut===j.Connected&&this.Ft());void 0===this.Bt&&(t<0&&(t=0),this.Bt=setTimeout((async()=>{this.ut===j.Connected&&await this.Ft()}),t))}}serverTimeout(){this.connection.stop(new Error("Server timeout elapsed without receiving a message from the server."))}async Wt(t){const n=t.target.toLowerCase(),i=this.nt[n];if(!i)return this.u.log(e.Warning,`No client method with the name '${n}' found.`),void(t.invocationId&&(this.u.log(e.Warning,`No result given for '${n}' method and invocation ID '${t.invocationId}'.`),await this.xt(this.Xt(t.invocationId,"Client didn't provide a result.",null))));const o=i.slice(),r=!!t.invocationId;let s,a,c;for(const i of o)try{const o=s;s=await i.apply(this,t.arguments),r&&s&&o&&(this.u.log(e.Error,`Multiple results provided for '${n}'. Sending error to server.`),c=this.Xt(t.invocationId,"Client provided multiple results.",null)),a=void 0}catch(t){a=t,this.u.log(e.Error,`A callback for the method '${n}' threw error '${t}'.`)}c?await this.xt(c):r?(a?c=this.Xt(t.invocationId,`${a}`,null):void 0!==s?c=this.Xt(t.invocationId,null,s):(this.u.log(e.Warning,`No result given for '${n}' method and invocation ID '${t.invocationId}'.`),c=this.Xt(t.invocationId,"Client didn't provide a result.",null)),await this.xt(c)):s&&this.u.log(e.Error,`Result given for '${n}' method but server is not expecting a result.`)}st(t){this.u.log(e.Debug,`HubConnection.connectionClosed(${t}) called while in state ${this.ut}.`),this.vt=this.vt||t||new r("The underlying connection was closed before the hub handshake could complete."),this.bt&&this.bt(),this.Jt(t||new Error("Invocation canceled due to the underlying connection being closed.")),this.Ct(),this.Tt(),this.ut===j.Disconnecting?this.Dt(t):this.ut===j.Connected&&this.Z?this.zt(t):this.ut===j.Connected&&this.Dt(t)}Dt(t){if(this.dt){this.ut=j.Disconnected,this.dt=!1,this.Pt&&(this.Pt.X(null!=t?t:new Error("Connection closed.")),this.Pt=void 0),g.isBrowser&&window.document.removeEventListener("freeze",this.G);try{this.rt.forEach((e=>e.apply(this,[t])))}catch(n){this.u.log(e.Error,`An onclose callback called with error '${t}' threw error '${n}'.`)}}}async zt(t){const n=Date.now();let i=0,o=void 0!==t?t:new Error("Attempting to reconnect due to a unknown error."),r=this.Vt(i,0,o);if(null===r)return this.u.log(e.Debug,"Connection not reconnecting because the IRetryPolicy returned null on the first reconnect attempt."),void this.Dt(t);if(this.ut=j.Reconnecting,t?this.u.log(e.Information,`Connection reconnecting because of error '${t}'.`):this.u.log(e.Information,"Connection reconnecting."),0!==this.ot.length){try{this.ot.forEach((e=>e.apply(this,[t])))}catch(n){this.u.log(e.Error,`An onreconnecting callback called with error '${t}' threw error '${n}'.`)}if(this.ut!==j.Reconnecting)return void this.u.log(e.Debug,"Connection left the reconnecting state in onreconnecting callback. Done reconnecting.")}for(;null!==r;){if(this.u.log(e.Information,`Reconnect attempt number ${i+1} will start in ${r} ms.`),await new Promise((t=>{this.Ht=setTimeout(t,r)})),this.Ht=void 0,this.ut!==j.Reconnecting)return void this.u.log(e.Debug,"Connection left the reconnecting state during reconnect delay. Done reconnecting.");try{if(await this.yt(),this.ut=j.Connected,this.u.log(e.Information,"HubConnection reconnected successfully."),0!==this.ht.length)try{this.ht.forEach((t=>t.apply(this,[this.connection.connectionId])))}catch(t){this.u.log(e.Error,`An onreconnected callback called with connectionId '${this.connection.connectionId}; threw error '${t}'.`)}return}catch(t){if(this.u.log(e.Information,`Reconnect attempt failed because of error '${t}'.`),this.ut!==j.Reconnecting)return this.u.log(e.Debug,`Connection moved to the '${this.ut}' from the reconnecting state during reconnect attempt. Done reconnecting.`),void(this.ut===j.Disconnecting&&this.Dt());i++,o=t instanceof Error?t:new Error(t.toString()),r=this.Vt(i,Date.now()-n,o)}}this.u.log(e.Information,`Reconnect retries have been exhausted after ${Date.now()-n} ms and ${i} failed attempts. Connection disconnecting.`),this.Dt()}Vt(t,n,i){try{return this.Z.nextRetryDelayInMilliseconds({elapsedMilliseconds:n,previousRetryCount:t,retryReason:i})}catch(i){return this.u.log(e.Error,`IRetryPolicy.nextRetryDelayInMilliseconds(${t}, ${n}) threw error '${i}'.`),null}}Jt(t){const n=this.it;this.it={},Object.keys(n).forEach((i=>{const o=n[i];try{o(null,t)}catch(n){this.u.log(e.Error,`Stream 'error' callback called with '${t}' threw error: ${T(n)}`)}}))}Tt(){this.Bt&&(clearTimeout(this.Bt),this.Bt=void 0)}Ct(){this.Ot&&clearTimeout(this.Ot)}Mt(t,e,n,i){if(n)return 0!==i.length?{target:t,arguments:e,streamIds:i,type:N.Invocation}:{target:t,arguments:e,type:N.Invocation};{const n=this.ct;return this.ct++,0!==i.length?{target:t,arguments:e,invocationId:n.toString(),streamIds:i,type:N.Invocation}:{target:t,arguments:e,invocationId:n.toString(),type:N.Invocation}}}qt(t,e){if(0!==t.length){e||(e=Promise.resolve());for(const n in t)t[n].subscribe({complete:()=>{e=e.then((()=>this.xt(this.Xt(n))))},error:t=>{let i;i=t instanceof Error?t.message:t&&t.toString?t.toString():"Unknown error",e=e.then((()=>this.xt(this.Xt(n,i))))},next:t=>{e=e.then((()=>this.xt(this.Kt(n,t))))}})}}Ut(t){const e=[],n=[];for(let i=0;i0)&&(e=!1,this.te=await this.Zt()),this.ee(t);const n=await this.Yt.send(t);return e&&401===n.statusCode&&this.Zt?(this.te=await this.Zt(),this.ee(t),await this.Yt.send(t)):n}ee(t){t.headers||(t.headers={}),this.te?t.headers[L.Authorization]=`Bearer ${this.te}`:this.Zt&&t.headers[L.Authorization]&&delete t.headers[L.Authorization]}getCookieString(t){return this.Yt.getCookieString(t)}}var U,B;!function(t){t[t.None=0]="None",t[t.WebSockets=1]="WebSockets",t[t.ServerSentEvents=2]="ServerSentEvents",t[t.LongPolling=4]="LongPolling"}(U||(U={})),function(t){t[t.Text=1]="Text",t[t.Binary=2]="Binary"}(B||(B={}));class W{constructor(){this.se=!1,this.onabort=null}abort(){this.se||(this.se=!0,this.onabort&&this.onabort())}get signal(){return this}get aborted(){return this.se}}class X{get pollAborted(){return this.ie.aborted}constructor(t,e,n){this.$=t,this.u=e,this.ie=new W,this.ne=n,this.re=!1,this.onreceive=null,this.onclose=null}async connect(t,n){if(m.isRequired(t,"url"),m.isRequired(n,"transferFormat"),m.isIn(n,B,"transferFormat"),this.oe=t,this.u.log(e.Trace,"(LongPolling transport) Connecting."),n===B.Binary&&"undefined"!=typeof XMLHttpRequest&&"string"!=typeof(new XMLHttpRequest).responseType)throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported.");const[o,r]=C(),s={[o]:r,...this.ne.headers},a={abortSignal:this.ie.signal,headers:s,timeout:1e5,withCredentials:this.ne.withCredentials};n===B.Binary&&(a.responseType="arraybuffer");const c=`${t}&_=${Date.now()}`;this.u.log(e.Trace,`(LongPolling transport) polling: ${c}.`);const l=await this.$.get(c,a);200!==l.statusCode?(this.u.log(e.Error,`(LongPolling transport) Unexpected response code: ${l.statusCode}.`),this.he=new i(l.statusText||"",l.statusCode),this.re=!1):this.re=!0,this.ce=this.ae(this.oe,a)}async ae(t,n){try{for(;this.re;)try{const o=`${t}&_=${Date.now()}`;this.u.log(e.Trace,`(LongPolling transport) polling: ${o}.`);const r=await this.$.get(o,n);204===r.statusCode?(this.u.log(e.Information,"(LongPolling transport) Poll terminated by server."),this.re=!1):200!==r.statusCode?(this.u.log(e.Error,`(LongPolling transport) Unexpected response code: ${r.statusCode}.`),this.he=new i(r.statusText||"",r.statusCode),this.re=!1):r.content?(this.u.log(e.Trace,`(LongPolling transport) data received. ${y(r.content,this.ne.logMessageContent)}.`),this.onreceive&&this.onreceive(r.content)):this.u.log(e.Trace,"(LongPolling transport) Poll timed out, reissuing.")}catch(t){this.re?t instanceof o?this.u.log(e.Trace,"(LongPolling transport) Poll timed out, reissuing."):(this.he=t,this.re=!1):this.u.log(e.Trace,`(LongPolling transport) Poll errored after shutdown: ${t.message}`)}}finally{this.u.log(e.Trace,"(LongPolling transport) Polling complete."),this.pollAborted||this.le()}}async send(t){return this.re?b(this.u,"LongPolling",this.$,this.oe,t,this.ne):Promise.reject(new Error("Cannot send until the transport is connected"))}async stop(){this.u.log(e.Trace,"(LongPolling transport) Stopping polling."),this.re=!1,this.ie.abort();try{await this.ce,this.u.log(e.Trace,`(LongPolling transport) sending DELETE request to ${this.oe}.`);const t={},[n,o]=C();t[n]=o;const r={headers:{...t,...this.ne.headers},timeout:this.ne.timeout,withCredentials:this.ne.withCredentials};let s;try{await this.$.delete(this.oe,r)}catch(t){s=t}s?s instanceof i&&(404===s.statusCode?this.u.log(e.Trace,"(LongPolling transport) A 404 response was returned from sending a DELETE request."):this.u.log(e.Trace,`(LongPolling transport) Error sending a DELETE request: ${s}`)):this.u.log(e.Trace,"(LongPolling transport) DELETE request accepted.")}finally{this.u.log(e.Trace,"(LongPolling transport) Stop finished."),this.le()}}le(){if(this.onclose){let t="(LongPolling transport) Firing onclose event.";this.he&&(t+=" Error: "+this.he),this.u.log(e.Trace,t),this.onclose(this.he)}}}class V{constructor(t,e,n,i){this.$=t,this.te=e,this.u=n,this.ne=i,this.onreceive=null,this.onclose=null}async connect(t,n){return m.isRequired(t,"url"),m.isRequired(n,"transferFormat"),m.isIn(n,B,"transferFormat"),this.u.log(e.Trace,"(SSE transport) Connecting."),this.oe=t,this.te&&(t+=(t.indexOf("?")<0?"?":"&")+`access_token=${encodeURIComponent(this.te)}`),new Promise(((i,o)=>{let r,s=!1;if(n===B.Text){if(g.isBrowser||g.isWebWorker)r=new this.ne.EventSource(t,{withCredentials:this.ne.withCredentials});else{const e=this.$.getCookieString(t),n={};n.Cookie=e;const[i,o]=C();n[i]=o,r=new this.ne.EventSource(t,{withCredentials:this.ne.withCredentials,headers:{...n,...this.ne.headers}})}try{r.onmessage=t=>{if(this.onreceive)try{this.u.log(e.Trace,`(SSE transport) data received. ${y(t.data,this.ne.logMessageContent)}.`),this.onreceive(t.data)}catch(t){return void this.ue(t)}},r.onerror=t=>{s?this.ue():o(new Error("EventSource failed to connect. The connection could not be found on the server, either the connection ID is not present on the server, or a proxy is refusing/buffering the connection. If you have multiple servers check that sticky sessions are enabled."))},r.onopen=()=>{this.u.log(e.Information,`SSE connected to ${this.oe}`),this.de=r,s=!0,i()}}catch(t){return void o(t)}}else o(new Error("The Server-Sent Events transport only supports the 'Text' transfer format"))}))}async send(t){return this.de?b(this.u,"SSE",this.$,this.oe,t,this.ne):Promise.reject(new Error("Cannot send until the transport is connected"))}stop(){return this.ue(),Promise.resolve()}ue(t){this.de&&(this.de.close(),this.de=void 0,this.onclose&&this.onclose(t))}}class J{constructor(t,e,n,i,o,r){this.u=n,this.Zt=e,this.fe=i,this.pe=o,this.$=t,this.onreceive=null,this.onclose=null,this.we=r}async connect(t,n){let i;return m.isRequired(t,"url"),m.isRequired(n,"transferFormat"),m.isIn(n,B,"transferFormat"),this.u.log(e.Trace,"(WebSockets transport) Connecting."),this.Zt&&(i=await this.Zt()),new Promise(((o,r)=>{let s;t=t.replace(/^http/,"ws");const a=this.$.getCookieString(t);let c=!1;if(g.isNode||g.isReactNative){const e={},[n,o]=C();e[n]=o,i&&(e[L.Authorization]=`Bearer ${i}`),a&&(e[L.Cookie]=a),s=new this.pe(t,void 0,{headers:{...e,...this.we}})}else i&&(t+=(t.indexOf("?")<0?"?":"&")+`access_token=${encodeURIComponent(i)}`);s||(s=new this.pe(t)),n===B.Binary&&(s.binaryType="arraybuffer"),s.onopen=n=>{this.u.log(e.Information,`WebSocket connected to ${t}.`),this.ge=s,c=!0,o()},s.onerror=t=>{let n=null;n="undefined"!=typeof ErrorEvent&&t instanceof ErrorEvent?t.error:"There was an error with the transport",this.u.log(e.Information,`(WebSockets transport) ${n}.`)},s.onmessage=t=>{if(this.u.log(e.Trace,`(WebSockets transport) data received. ${y(t.data,this.fe)}.`),this.onreceive)try{this.onreceive(t.data)}catch(t){return void this.ue(t)}},s.onclose=t=>{if(c)this.ue(t);else{let e=null;e="undefined"!=typeof ErrorEvent&&t instanceof ErrorEvent?t.error:"WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.",r(new Error(e))}}}))}send(t){return this.ge&&this.ge.readyState===this.pe.OPEN?(this.u.log(e.Trace,`(WebSockets transport) sending data. ${y(t,this.fe)}.`),this.ge.send(t),Promise.resolve()):Promise.reject("WebSocket is not in the OPEN state")}stop(){return this.ge&&this.ue(void 0),Promise.resolve()}ue(t){this.ge&&(this.ge.onclose=()=>{},this.ge.onmessage=()=>{},this.ge.onerror=()=>{},this.ge.close(),this.ge=void 0),this.u.log(e.Trace,"(WebSockets transport) socket closed."),this.onclose&&(!this.me(t)||!1!==t.wasClean&&1e3===t.code?t instanceof Error?this.onclose(t):this.onclose():this.onclose(new Error(`WebSocket closed with status code: ${t.code} (${t.reason||"no reason given"}).`)))}me(t){return t&&"boolean"==typeof t.wasClean&&"number"==typeof t.code}}class Z{constructor(t,n={}){var i;if(this.ye=()=>{},this.features={},this.ve=1,m.isRequired(t,"url"),this.u=void 0===(i=n.logger)?new w(e.Information):null===i?p.instance:void 0!==i.log?i:new w(i),this.baseUrl=this.be(t),(n=n||{}).logMessageContent=void 0!==n.logMessageContent&&n.logMessageContent,"boolean"!=typeof n.withCredentials&&void 0!==n.withCredentials)throw new Error("withCredentials option was not a 'boolean' or 'undefined' value");n.withCredentials=void 0===n.withCredentials||n.withCredentials,n.timeout=void 0===n.timeout?1e5:n.timeout;let o=null,r=null;if(g.isNode){const t=require;o=t("ws"),r=t("eventsource")}g.isNode||"undefined"==typeof WebSocket||n.WebSocket?g.isNode&&!n.WebSocket&&o&&(n.WebSocket=o):n.WebSocket=WebSocket,g.isNode||"undefined"==typeof EventSource||n.EventSource?g.isNode&&!n.EventSource&&void 0!==r&&(n.EventSource=r):n.EventSource=EventSource,this.$=new F(n.httpClient||new D(this.u),n.accessTokenFactory),this.ut="Disconnected",this.dt=!1,this.ne=n,this.onreceive=null,this.onclose=null}async start(t){if(t=t||B.Binary,m.isIn(t,B,"transferFormat"),this.u.log(e.Debug,`Starting connection with transfer format '${B[t]}'.`),"Disconnected"!==this.ut)return Promise.reject(new Error("Cannot start an HttpConnection that is not in the 'Disconnected' state."));if(this.ut="Connecting",this.Ee=this.yt(t),await this.Ee,"Disconnecting"===this.ut){const t="Failed to start the HttpConnection before stop() was called.";return this.u.log(e.Error,t),await this.It,Promise.reject(new r(t))}if("Connected"!==this.ut){const t="HttpConnection.startInternal completed gracefully but didn't enter the connection into the connected state!";return this.u.log(e.Error,t),Promise.reject(new r(t))}this.dt=!0}send(t){return"Connected"!==this.ut?Promise.reject(new Error("Cannot send data if the connection is not in the 'Connected' State.")):(this.$e||(this.$e=new Y(this.transport)),this.$e.send(t))}async stop(t){return"Disconnected"===this.ut?(this.u.log(e.Debug,`Call to HttpConnection.stop(${t}) ignored because the connection is already in the disconnected state.`),Promise.resolve()):"Disconnecting"===this.ut?(this.u.log(e.Debug,`Call to HttpConnection.stop(${t}) ignored because the connection is already in the disconnecting state.`),this.It):(this.ut="Disconnecting",this.It=new Promise((t=>{this.ye=t})),await this._t(t),void await this.It)}async _t(t){this.Ce=t;try{await this.Ee}catch(t){}if(this.transport){try{await this.transport.stop()}catch(t){this.u.log(e.Error,`HttpConnection.transport.stop() threw error '${t}'.`),this.Se()}this.transport=void 0}else this.u.log(e.Debug,"HttpConnection.transport is undefined in HttpConnection.stop() because start() failed.")}async yt(t){let n=this.baseUrl;this.Zt=this.ne.accessTokenFactory,this.$.Zt=this.Zt;try{if(this.ne.skipNegotiation){if(this.ne.transport!==U.WebSockets)throw new Error("Negotiation can only be skipped when using the WebSocket transport directly.");this.transport=this.ke(U.WebSockets),await this.Pe(n,t)}else{let e=null,i=0;do{if(e=await this.Te(n),"Disconnecting"===this.ut||"Disconnected"===this.ut)throw new r("The connection was stopped during negotiation.");if(e.error)throw new Error(e.error);if(e.ProtocolVersion)throw new Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");if(e.url&&(n=e.url),e.accessToken){const t=e.accessToken;this.Zt=()=>t,this.$.te=t,this.$.Zt=void 0}i++}while(e.url&&i<100);if(100===i&&e.url)throw new Error("Negotiate redirection limit exceeded.");await this.Ie(n,this.ne.transport,e,t)}this.transport instanceof X&&(this.features.inherentKeepAlive=!0),"Connecting"===this.ut&&(this.u.log(e.Debug,"The HttpConnection connected successfully."),this.ut="Connected")}catch(t){return this.u.log(e.Error,"Failed to start the connection: "+t),this.ut="Disconnected",this.transport=void 0,this.ye(),Promise.reject(t)}}async Te(t){const n={},[o,r]=C();n[o]=r;const s=this._e(t);this.u.log(e.Debug,`Sending negotiation request: ${s}.`);try{const t=await this.$.post(s,{content:"",headers:{...n,...this.ne.headers},timeout:this.ne.timeout,withCredentials:this.ne.withCredentials});if(200!==t.statusCode)return Promise.reject(new Error(`Unexpected status code returned from negotiate '${t.statusCode}'`));const e=JSON.parse(t.content);return(!e.negotiateVersion||e.negotiateVersion<1)&&(e.connectionToken=e.connectionId),e.useStatefulReconnect&&!0!==this.ne.He?Promise.reject(new l("Client didn't negotiate Stateful Reconnect but the server did.")):e}catch(t){let n="Failed to complete negotiation with the server: "+t;return t instanceof i&&404===t.statusCode&&(n+=" Either this is not a SignalR endpoint or there is a proxy blocking the connection."),this.u.log(e.Error,n),Promise.reject(new l(n))}}De(t,e){return e?t+(-1===t.indexOf("?")?"?":"&")+`id=${e}`:t}async Ie(t,n,i,o){let s=this.De(t,i.connectionToken);if(this.Re(n))return this.u.log(e.Debug,"Connection was provided an instance of ITransport, using that directly."),this.transport=n,await this.Pe(s,o),void(this.connectionId=i.connectionId);const a=[],l=i.availableTransports||[];let d=i;for(const i of l){const l=this.xe(i,n,o,!0===(null==d?void 0:d.useStatefulReconnect));if(l instanceof Error)a.push(`${i.transport} failed:`),a.push(l);else if(this.Re(l)){if(this.transport=l,!d){try{d=await this.Te(t)}catch(t){return Promise.reject(t)}s=this.De(t,d.connectionToken)}try{return await this.Pe(s,o),void(this.connectionId=d.connectionId)}catch(t){if(this.u.log(e.Error,`Failed to start the transport '${i.transport}': ${t}`),d=void 0,a.push(new c(`${i.transport} failed: ${t}`,U[i.transport])),"Connecting"!==this.ut){const t="Failed to select transport before stop() was called.";return this.u.log(e.Debug,t),Promise.reject(new r(t))}}}}return a.length>0?Promise.reject(new u(`Unable to connect to the server with any of the available transports. ${a.join(" ")}`,a)):Promise.reject(new Error("None of the transports supported by the client are supported by the server."))}ke(t){switch(t){case U.WebSockets:if(!this.ne.WebSocket)throw new Error("'WebSocket' is not supported in your environment.");return new J(this.$,this.Zt,this.u,this.ne.logMessageContent,this.ne.WebSocket,this.ne.headers||{});case U.ServerSentEvents:if(!this.ne.EventSource)throw new Error("'EventSource' is not supported in your environment.");return new V(this.$,this.$.te,this.u,this.ne);case U.LongPolling:return new X(this.$,this.u,this.ne);default:throw new Error(`Unknown transport: ${t}.`)}}Pe(t,e){return this.transport.onreceive=this.onreceive,this.features.reconnect?this.transport.onclose=async n=>{let i=!1;if(this.features.reconnect){try{this.features.disconnected(),await this.transport.connect(t,e),await this.features.resend()}catch{i=!0}i&&this.Se(n)}else this.Se(n)}:this.transport.onclose=t=>this.Se(t),this.transport.connect(t,e)}xe(t,n,i,o){const r=U[t.transport];if(null==r)return this.u.log(e.Debug,`Skipping transport '${t.transport}' because it is not supported by this client.`),new Error(`Skipping transport '${t.transport}' because it is not supported by this client.`);if(!function(t,e){return!t||!!(e&t)}(n,r))return this.u.log(e.Debug,`Skipping transport '${U[r]}' because it was disabled by the client.`),new a(`'${U[r]}' is disabled by the client.`,r);if(!(t.transferFormats.map((t=>B[t])).indexOf(i)>=0))return this.u.log(e.Debug,`Skipping transport '${U[r]}' because it does not support the requested transfer format '${B[i]}'.`),new Error(`'${U[r]}' does not support ${B[i]}.`);if(r===U.WebSockets&&!this.ne.WebSocket||r===U.ServerSentEvents&&!this.ne.EventSource)return this.u.log(e.Debug,`Skipping transport '${U[r]}' because it is not supported in your environment.'`),new s(`'${U[r]}' is not supported in your environment.`,r);this.u.log(e.Debug,`Selecting transport '${U[r]}'.`);try{return this.features.reconnect=r===U.WebSockets?o:void 0,this.ke(r)}catch(t){return t}}Re(t){return t&&"object"==typeof t&&"connect"in t}Se(t){if(this.u.log(e.Debug,`HttpConnection.stopConnection(${t}) called while in state ${this.ut}.`),this.transport=void 0,t=this.Ce||t,this.Ce=void 0,"Disconnected"!==this.ut){if("Connecting"===this.ut)throw this.u.log(e.Warning,`Call to HttpConnection.stopConnection(${t}) was ignored because the connection is still in the connecting state.`),new Error(`HttpConnection.stopConnection(${t}) was called while the connection is still in the connecting state.`);if("Disconnecting"===this.ut&&this.ye(),t?this.u.log(e.Error,`Connection disconnected with error '${t}'.`):this.u.log(e.Information,"Connection disconnected."),this.$e&&(this.$e.stop().catch((t=>{this.u.log(e.Error,`TransportSendQueue.stop() threw error '${t}'.`)})),this.$e=void 0),this.connectionId=void 0,this.ut="Disconnected",this.dt){this.dt=!1;try{this.onclose&&this.onclose(t)}catch(n){this.u.log(e.Error,`HttpConnection.onclose(${t}) threw error '${n}'.`)}}}else this.u.log(e.Debug,`Call to HttpConnection.stopConnection(${t}) was ignored because the connection is already in the disconnected state.`)}be(t){if(0===t.lastIndexOf("https://",0)||0===t.lastIndexOf("http://",0))return t;if(!g.isBrowser)throw new Error(`Cannot resolve '${t}'.`);const n=window.document.createElement("a");return n.href=t,this.u.log(e.Information,`Normalizing '${t}' to '${n.href}'.`),n.href}_e(t){const e=new URL(t);e.pathname.endsWith("/")?e.pathname+="negotiate":e.pathname+="/negotiate";const n=new URLSearchParams(e.searchParams);return n.has("negotiateVersion")||n.append("negotiateVersion",this.ve.toString()),n.has("useStatefulReconnect")?"true"===n.get("useStatefulReconnect")&&(this.ne.He=!0):!0===this.ne.He&&n.append("useStatefulReconnect","true"),e.search=n.toString(),e.toString()}}class Y{constructor(t){this.Ae=t,this.Ue=[],this.Le=!0,this.Ne=new Q,this.qe=new Q,this.Me=this.je()}send(t){return this.We(t),this.qe||(this.qe=new Q),this.qe.promise}stop(){return this.Le=!1,this.Ne.resolve(),this.Me}We(t){if(this.Ue.length&&typeof this.Ue[0]!=typeof t)throw new Error(`Expected data to be of type ${typeof this.Ue} but was of type ${typeof t}`);this.Ue.push(t),this.Ne.resolve()}async je(){for(;;){if(await this.Ne.promise,!this.Le){this.qe&&this.qe.reject("Connection stopped.");break}this.Ne=new Q;const t=this.qe;this.qe=void 0;const e="string"==typeof this.Ue[0]?this.Ue.join(""):Y.Oe(this.Ue);this.Ue.length=0;try{await this.Ae.send(e),t.resolve()}catch(e){t.reject(e)}}}static Oe(t){const e=t.map((t=>t.byteLength)).reduce(((t,e)=>t+e)),n=new Uint8Array(e);let i=0;for(const e of t)n.set(new Uint8Array(e),i),i+=e.byteLength;return n.buffer}}class Q{constructor(){this.promise=new Promise(((t,e)=>[this.j,this.Fe]=[t,e]))}resolve(){this.j()}reject(t){this.Fe(t)}}class K{constructor(){this.name="json",this.version=2,this.transferFormat=B.Text}parseMessages(t,n){if("string"!=typeof t)throw new Error("Invalid input for JSON hub protocol. Expected a string.");if(!t)return[];null===n&&(n=p.instance);const i=A.parse(t),o=[];for(const t of i){const i=JSON.parse(t);if("number"!=typeof i.type)throw new Error("Invalid payload.");switch(i.type){case N.Invocation:this.U(i);break;case N.StreamItem:this.Be(i);break;case N.Completion:this.Xe(i);break;case N.Ping:case N.Close:break;case N.Ack:this.Je(i);break;case N.Sequence:this.ze(i);break;default:n.log(e.Information,"Unknown message type '"+i.type+"' ignored.");continue}o.push(i)}return o}writeMessage(t){return A.write(JSON.stringify(t))}U(t){this.Ve(t.target,"Invalid payload for Invocation message."),void 0!==t.invocationId&&this.Ve(t.invocationId,"Invalid payload for Invocation message.")}Be(t){if(this.Ve(t.invocationId,"Invalid payload for StreamItem message."),void 0===t.item)throw new Error("Invalid payload for StreamItem message.")}Xe(t){if(t.result&&t.error)throw new Error("Invalid payload for Completion message.");!t.result&&t.error&&this.Ve(t.error,"Invalid payload for Completion message."),this.Ve(t.invocationId,"Invalid payload for Completion message.")}Je(t){if("number"!=typeof t.sequenceId)throw new Error("Invalid SequenceId for Ack message.")}ze(t){if("number"!=typeof t.sequenceId)throw new Error("Invalid SequenceId for Sequence message.")}Ve(t,e){if("string"!=typeof t||""===t)throw new Error(e)}}const G={trace:e.Trace,debug:e.Debug,info:e.Information,information:e.Information,warn:e.Warning,warning:e.Warning,error:e.Error,critical:e.Critical,none:e.None};class tt{configureLogging(t){if(m.isRequired(t,"logging"),void 0!==t.log)this.logger=t;else if("string"==typeof t){const e=function(t){const e=G[t.toLowerCase()];if(void 0!==e)return e;throw new Error(`Unknown log level: ${t}`)}(t);this.logger=new w(e)}else this.logger=new w(t);return this}withUrl(t,e){return m.isRequired(t,"url"),m.isNotEmpty(t,"url"),this.url=t,this.httpConnectionOptions="object"==typeof e?{...this.httpConnectionOptions,...e}:{...this.httpConnectionOptions,transport:e},this}withHubProtocol(t){return m.isRequired(t,"protocol"),this.protocol=t,this}withAutomaticReconnect(t){if(this.reconnectPolicy)throw new Error("A reconnectPolicy has already been set.");return t?Array.isArray(t)?this.reconnectPolicy=new H(t):this.reconnectPolicy=t:this.reconnectPolicy=new H,this}withServerTimeout(t){return m.isRequired(t,"milliseconds"),this.Ke=t,this}withKeepAliveInterval(t){return m.isRequired(t,"milliseconds"),this.Ge=t,this}withStatefulReconnect(t){return void 0===this.httpConnectionOptions&&(this.httpConnectionOptions={}),this.httpConnectionOptions.He=!0,this.Y=null==t?void 0:t.bufferSize,this}build(){const t=this.httpConnectionOptions||{};if(void 0===t.logger&&(t.logger=this.logger),!this.url)throw new Error("The 'HubConnectionBuilder.withUrl' method must be called before building the connection.");const e=new Z(this.url,t);return O.create(e,this.logger||p.instance,this.protocol||new K,this.reconnectPolicy,this.Ke,this.Ge,this.Y)}}return Uint8Array.prototype.indexOf||Object.defineProperty(Uint8Array.prototype,"indexOf",{value:Array.prototype.indexOf,writable:!0}),Uint8Array.prototype.slice||Object.defineProperty(Uint8Array.prototype,"slice",{value:function(t,e){return new Uint8Array(Array.prototype.slice.call(this,t,e))},writable:!0}),Uint8Array.prototype.forEach||Object.defineProperty(Uint8Array.prototype,"forEach",{value:Array.prototype.forEach,writable:!0}),n})(),"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.signalR=e():t.signalR=e(),$.extend($t,{t1:"Eingabe erforderlich",t2:"Bitte überprüfen Sie Ihre Eingaben im Formular.",b0:"Erstellt",b1:"Zuletzt geändert",b2:"von",t12:"Der Server hat einen Fehler zurückgegeben. Bitte versuchen Sie es erneut.",t17:"Eine Email mit einem Aktivierungs-Link wurde an deine Adresse versandt.",t18:"Ein Account mit deinem Namen existiert bereits. Dennoch erstellen?",t19:"Einträge sind entweder unngültig oder zu kurz.",t20:"Der Server hat einen Fehler gemeldet. Bitte versuch es erneut.",t21:"Der Zugang wurde nicht gefunden.",t30a:"Als erledigt markieren.",t30b:"Als unerledigt markieren.",t55:"Ein Email mit einem Aktivierungs-Link wurde an Ihre Adresse versandt.",t56:"Ein Zugang für diesen Namen besteht bereits. Trotzdem erstellen?",t57:"Ein bestehender Zugang wurde für diese Serie registriert.",t60:"Bitte geben Sie Email-Adresse an, die Sie hier hinterlegt haben.",t61:"Ihr Passwort wurde erfolgreich versandt.",t62:"Die angegebene Email-Adresse stimmt nicht mit der hier hinterlegten überein.",ov:"Persönliche Übersicht"});var $v={}; /*! loadCSS. [c]2020 Filament Group, Inc. MIT License */ /*! onloadCSS. (onload callback for loadCSS) [c]2017 Filament Group, Inc. MIT License */ -function onloadCSS(t,e){e=e||{};let n=function(e){return new Promise(((n,i)=>{t.addEventListener?e.addEventListener("load",newcb):t.attachEvent&&e.attachEvent("onload",newcb),"isApplicationInstalled"in navigator&&"onloadcssdefined"in t&&e.onloadcssdefined(newcb)}))};if(Array.isArray(t)){let i=t.length;Promise.all(t.map(n)).then((function(t){var n=t.reduce(((t,e)=>t+(!0===e?1:0)));!async function(t){!0===t&&"function"==typeof e.success?e.success():!0===t&&"object"==typeof e.success&&e.success instanceof Promise&&await e.success(),e.complete()}(i===n)}))}else n(t)}!function(t){"use strict";var e=function(e,n,i,o){var r,s=t.document,a=s.createElement("link");if(n)r=n;else{var c=(s.body||s.getElementsByTagName("head")[0]).childNodes;r=c[c.length-1]}var l=s.styleSheets;if(o)for(var u in o)o.hasOwnProperty(u)&&a.setAttribute(u,o[u]);a.rel="stylesheet",a.href=e,a.media="only x",function t(e){if(s.body)return e();setTimeout((function(){t(e)}))}((function(){r.parentNode.insertBefore(a,n?r:r.nextSibling)}));var d=function(t){for(var e=a.href,n=l.length;n--;)if(l[n].href===e)return t();setTimeout((function(){d(t)}))};function h(){a.addEventListener&&a.removeEventListener("load",h),a.media=i||"all"}return a.addEventListener&&a.addEventListener("load",h),a.onloadcssdefined=d,d(h),a};"undefined"!=typeof exports?exports.loadCSS=e:t.loadCSS=e}("undefined"!=typeof global?global:this);const isIE=/MSIE\/|Trident/gi.test(window.navigator.userAgent)||void 0!==window.document.documentMode,isfileapi=!!(window.File&&window.FileReader&&window.FileList&&window.Blob);var $ocms={auth:{},no:function(t){t.stopPropagation()},vmin:function(t){var e=$(window).width*(t||1),n=$(window).height*(t||1);return e($ocms.baseurl+"/"+(t||"")).replace(/\/\//,"/"),cexi:null};function deepCopy(t){var e,n,i;if("object"!=typeof t||null===t)return t;for(i in e=Array.isArray(t)?[]:{},t)n=t[i],e[i]=deepCopy(n);return e}function fields_definition(t,e,n){this.label_sng=!0===Array.isArray(t)?"":t||"",this.label_pl=!0===Array.isArray(t)?"":e||"",this.fields=!0===Array.isArray(t)?t:n||[],this.itm=function(t){for(var e=0;e0)for(var n=0;nt||"")).filter(((t,e)=>""!==t)).join(e)}function parseDt(t,e,n){t=(t||"").substr(0,e.length);var i=e,o=t.length>0&&e.split(";").some((function(e){for(var n,o=/[^yMdhms0-9]/gi,r=!0;null!==(n=o.exec(e));)r=r&&e.substr(n.index,1)===t.substr(n.index,1);var s=t.length===e.length&&r;return!0===s&&(i=e),s}));if(!0===o){for(var r,s=[0,0,0,0,0,0,0],a=/(mm{1,2}|dd{1,2}|MM{1,2}|(yy){2,4}|ss{1,2}|hh{1,2})(?!\w)/g;null!==(r=a.exec(i));)s["yMdhms".indexOf(r[0].substr(0,1))]=parseInt(("yy"===r[0]?"20":"")+t.substr(r.index,r[0].length))-("M"===r[0].substr(0,1)?1:0);var c=new(Function.prototype.bind.apply(Date,[null].concat(s)));return"string"==typeof n?fdt(c,n):c}return!1}function bool(t,e){return"boolean"==typeof t?t:"boolean"==typeof e&&e}function booln(t,e){return"boolean"==typeof t?t:"number"==typeof t?1===t:"boolean"==typeof e&&e}Date.prototype.isValid=function(){return!isNaN(this)},Date.prototype.format=function(t){return fdt(this,t)},Date.prototype.addDays=function(t){return this.setDate(this.getDate()+t),this},Date.prototype.isBetween=function(t,e){return this>t&&this section");$(window).scroll((function(e){let n=$(window).scrollTop(),i=$("body");i.toggleClass("unfocus",n>vh()-1.2*t),i.toggleClass("btb",n>.5*vh()-t)}))},$ocms.cf_reset=function(){return $("#contentframe").empty()},function(t){t.fn.scrollTo=function(e){if(t(this).length>0){var n=t(this).offset().top||0;n>0&&t("html, body").animate({scrollTop:n-hh()},2e3)}},t.fn.ldng=function(e){var n=!0;return"boolean"==typeof e?n=e:"number"==typeof e&&(n=e>0),t(this).toggleClass("loading",n)},"function"!=typeof t.noop&&(t.noop=function(){}),t.fn.hasAttr=function(e){var n=t(this).attr(e);return void 0!==n&&!1!==n},t.fn.parseCssPx=function(e){try{return parseFloat(t(this).css(e).replace("px","")||0)}catch(t){return 0}},t.max=function(t,e){return isNaN(t)&&isNaN(e)?null:isNaN(t)&&!isNaN(e)?e:!isNaN(e)&&isNaN(e)||t>=e?t:e},t.min=function(t,e){return isNaN(t)&&isNaN(e)?null:isNaN(t)&&!isNaN(e)?e:!isNaN(e)&&isNaN(e)||t<=e?t:e},t.lim=function(t,e){return isNaN(t)?null:isNaN(e)?t:e<=t?e:t},t.fn.enterKey=function(e){return this.each((function(){t(this).keypress((function(t){"13"===(t.keyCode?t.keyCode:t.which).toString()&&e.call(this,t)}))}))}}(jQuery),$ocms.defaultTimeout=3e4,$ocms.AjaxEX=function(t){var e=this;e.responseText=e.responseText||"";var n=e.getResponseHeader("x-ocms-code")||"";e.internalCode=""!==n&&!1===isNaN(n)?parseInt(n):-1,e.isInternal=e.internalCode>-1,e.internalText=decodeURIComponent((e.getResponseHeader("x-ocms-desc")||"").replace(/\+/g,"%20")||"");var i=e.internalText||t,o=e.internalCode||e.status;e.logtext=i+" ("+o+")"},$ocms.postXTS=function(t){$ocms.postXT.call(this,$.extend(t,{sync:!0}))},$ocms.postXT=function(t){if((t=t||{}).trycount=t.trycount||0,""!==(t.url||"")){t.url=-1!==t.url.indexOf("&yy=")?t.url:t.url.indexOf("?")>-1?t.url+"&yy="+(new Date).getTime():t.url+"?yy="+(new Date).getTime();var e=t.context||this;switch(t.context=e,t.retryLimit=t.retryLimit||0,t.timeout=t.timeout||$ocms.defaultTimeout,t.timeout<100&&(t.timeout=1e3*t.timeout),t.data=t.data||{},t.contentType=t.contentType||"multipart/form-data; charset=UTF-8",t.islogin="boolean"==typeof t.islogin&&t.islogin,t.contentType){case"":case"json":t.contentType="application/json; charset=utf-8";break;case"form":t.contentType="application/x-www-form-urlencoded; charset=UTF-8";break;case"multi":t.contentType="multipart/form-data";break;case"text":t.contentType="text/plain; charset=UTF-8"}if(t.form instanceof jQuery?(t.data=t.form.serializeObject(),t.contentType="form-data"):t.lzw instanceof jQuery&&(t.data.lzw=$.ccLZW(t.lzw.serializeAnything(!0)).join(",")),"multipart/form-data"!==t.contentType.substr(0,19)&&"form-data"!==t.contentType.substr(0,9)||t.data instanceof FormData!=!1)t.data instanceof FormData&&(t.contentType=!1,t.processData=!1);else{t.contentType=!1;var n=new FormData;$.each(t.files||[],(function(t,e){n.append("upload_file",e)})),$.each(t.data||{},(function(t,e){n.append(t,e)})),t.data=n,t.processData=!1}var i={type:t.method||"post",url:t.url,data:t.data,processData:"boolean"!=typeof t.processData||t.processData,contentType:t.contentType,cache:t.cache||!1,timeout:t.timeout,beforeSend:function(n){$(t.loading).ldng(),$("body").addClass("ldng"),"function"==typeof t.beforesend&&t.beforesend.apply(e,[n])},success:function(n,i,o){"false"===n||"not authorized"===n?("function"==typeof t.error&&t.error.apply(e,[o,i,n]),"function"==typeof $.status&&$.status(i+" - "+n)):"function"==typeof t.success&&t.success.apply(e,[n,i,o])},error:function(n,i,o){if($ocms.AjaxEX.call(n,i),-1===t.url.indexOf("doc.ashx")||-1!==t.url.indexOf("ftest")){if(401===n.status&&111===n.internalCode&&!1===t.islogin&&"function"==typeof $ocms.login.dlg)$ocms.login.dlg({ajo:t});else if("timeout"===i||302===n.status)return t.tryCount++,t.tryCount<=t.retryLimit?void $ocms.postXT(t):void 0;"function"==typeof t.error?t.error.apply(e,[n,i,o]):"function"==typeof $ocms.failure?$ocms.failure.apply(e,[n]):"function"==typeof $.status&&$.status("Server error: "+i+" - "+o)}},dataType:t.datatype||"json",complete:function(n,i){"function"==typeof t.complete&&t.complete.apply(e,[n,i]),$(t.loading).ldng(0),$("body").removeClass("ldng");let o=$("body > .timer");if(o.length>0){let t=new Date(n.getResponseHeader("ocms_cec")||""),e=new Date(n.getResponseHeader("ocms_cex")||"");if(t.isValid()&&e.isValid()){let n=new Date,i=Math.abs(e-t);n.setMilliseconds(n.getMilliseconds()+i),o.data({cex:n,ctt:i}),$ocms.cex_timer()}}},context:e,async:!0};return"boolean"==typeof t.sync&&(i.async=!1===t.sync),!0==("boolean"==typeof t.contentType&&!1===t.contentType)&&(i.contentType=!1),$.ajax(i)}},$ocms.cex_timer=function(){$ocms.cexi||($ocms.cexi=setInterval($ocms.cex_timer,15e3));let t=$("body > .timer"),e=t.data("cex"),n=t.data("ctt"),i=new Date;if(e instanceof Date&&e.isValid()&&"number"==typeof n&&n>0&&e>i){let o=Math.abs(i-e)/n*100;t.css("width",o.toString()+"%"),o<98&&(!$ocms.cex_lp||Math.abs(i-$ocms.cex_lp)>6e5)&&$ocms.postXT({url:$ocms.url("ping"),success:()=>{$ocms.cex_lp=i},timeout:5e3,error:()=>{}})}},$ocms.vbl_send=function(t){var e=t.data||{};if(""!==(e.url||"")){var n=$("#contentframe form:first"),i={url:e.url,data:new FormData,success:function(t){"function"==typeof e.success?e.success(t):"string"==typeof e.success&&alert(e.success)},error:function(t,n,i){"function"==typeof e.error?e.error(i):"string"==typeof e.error&&alert(e.error)},complete:function(){n.ldng(0)}},o=!0;n.find("input").each((function(){var t=$(this),e=t.nza("name"),n=t.val(),r=$(this).prop("required")||!1;if(""!==e){var s=""!==n||!1===r;o=o&&s,!0===s?(i.data.append(e,n),t[0].setCustomValidity("")):""!==$(this).nza("ocms-nvnote")&&t[0].setCustomValidity($(this).nza("ocms-nvnote"))}})),!0===o&&(n.ldng(1),$ocms.postXT.call(this,i))}},function(t){t.fn.nza=function(e,n){var i=t(this).attr(e);return void 0!==i&&!1!==i?i:n||""},t.fn.serializeObject=function(e,n){var i=/\r?\n/g,o=/^(?:submit|button|image|reset|file)$/i,r=/^(?:input|select|textarea|keygen)/i,s=/^(?:checkbox|radio)$/i,a=bool((n=n||{}).typedvalues,!1),c={},l=t(this),u=l.find(':input:not([nosend],[type="file"])').addBack(":input"),d=!0;return t.each(u.not(".tinymce").get(),(function(n,l){var u=t(this),h=this,p=(this.type||"").toLowerCase(),f=u.prop("required")||!1;if(!0===(h.name&&!u.is(":disabled")&&r.test(h.nodeName)&&!o.test(p))){var m=u.val(),g=h.name,y=u.nza("data-format").split(":"),v=u.nza("pattern")||".*";if(!0===s.test(p)&&(m=h.checked?""!==m?m:"true":""),"date"===y[0].substr(0,4)&&y.length>1)"boolean"==typeof(m=parseDt(m,y.slice(1).join(":")))&&(m=null),null===m&&"date"===u.prop("type").substr(0,4)&&!1===isNaN(new Date(u.val()))&&(m=new Date(u.val())),m instanceof Date==!0&&"function"==typeof m.getMonth?!1===a&&(m=fdt(m,"date"===y[0]?"dts":"iso")):m=null;else if(("number"===p||"float"===y[0]||"integer"===y[0])&&!0===a){let t,e=String(m).replace(",",".");t="integer"===y[0]?parseInt(e):parseFloat(e),m=isNaN(t)?m:t}if(!0===f&&(""===m||null==m||"string"==typeof m&&null===m.match(v))?(!0===bool(e,!1)&&h.setCustomValidity(u.nza("ocms-nvnote",$ocms.t.inv||"Invalid field")),m=null):!0===bool(e,!1)&&h.setCustomValidity(""),null!=m&&"string"==typeof m){let t=c[g];null!=t?Array.isArray(t)?t.push(m.replace(i,"\r\n")):c[g]=[t,m.replace(i,"\r\n")]:c[g]=m.replace(i,"\r\n")}else if(null!=m){let t=c[g];null!=t?Array.isArray(t)?t.push(m):c[g]=[t,m]:c[g]=m}else d=!1}})),u.filter(".tinymce").each((function(e,n){var i=t(this),o=((this.type||"").toLowerCase(),i.prop("required")||!1);try{var r=tinymce.get(t(n).attr("id"));if(r){var s=t(n).attr("name"),a=r.getContent();!1===o||""!==(a||"")?c[s]=a:d=!1}}catch(e){t.noop()}})),l.toggleClass("invalid",!d),d?c:null},t.fn.sendForm=function(e,n,i){var o=t(this);i=i||{};var r={url:e,success:function(t){if(i.response=t,"function"==typeof n)n(t);o.closest("div.modal").remove()},error:function(t,e,n){"function"==typeof i.error?i.error.call(this,t):$ocms.failure.call(this,t)},complete:function(){o.ldng(0),"function"==typeof i.complete&&i.complete.call(this,jqXHR)}},s=o.find('input[type="file"]');r.data=new FormData,s.length>0&&t.each(s[0].files,(function(t,e){r.data.append(t,e),r.data.append("file_lastmodified",$ocms.isodt(e.lastModifiedDate))}));var a=o.serializeObject();t.each(a||{},(function(t,e){r.data.append(t,e)})),o.ldng(),$ocms.postXT.call(this,r)},t.fn.checkValidity=function(){var e=t(this),n=!0;return e.each((function(t,e){n=n&&e.checkValidity()})),n},t.fn.wrap=function(e,n){var i=t(this),o=$$.dc(e).attr(n||{}).insertAfter(i);return i.append(o),o}}(jQuery),$ocms.logout=function(){$ocms.postXT({url:$ocms.url("logout"),complete:function(){window.location.reload()}})},$ocms.login={send:function(t){t.preventDefault();var e=$(this);if(!0===e.find("#dbtn-confirm").hasClass("disabled"))return!1;var n=e.serializeObject();return n.loginaccount=ne(n.loginaccount,$ocms.auth.account||$ocms.auth.requestedaccount||""),n.loginaccount=ne(n.loginaccount,$ocms.auth.account||$ocms.auth.requestedaccount||""),""===ne(n.loginaccount)&&!0===bool($ocms.auth.accountrequired,!0)?(alert($t.l16),!1):($ocms.postXT({url:$ocms.url("login"),data:n,success:function(){window.location.reload()}}),!1)},uichange:function(){let t=$(this),e=t.closest("form"),n=bool($ocms.auth.accountrequired,!0),i=ne(e.find('[name="loginaccount"]').val(),$ocms.auth.account||$ocms.auth.requestedaccount||"");if(""!==i||!1===n){var o=e.find('[name="userlogin"]').empty().val(""),r=e.find('[name="username"]').empty().val(""),s=$("#dlg_userlogin_sel").empty().val(""),a=t.val()||"";if(!1===t.checkValidity()&&""===a)return;var c=t.closest("table").ldng();$ocms.postXT.call(this,{url:$ocms.url("auth"),data:{userinfo:a,account:i||""},success:function(t,e,n){if(1===t.length){var i=t[0];o.val(i.login).change().attr("required","").removeAttr("nosend"),r.val(i.name).change().attr("required","").show(),s.removeAttr("required").attr("nosend","").hide()}else t.length>0?(r.hide().removeAttr("required"),o.removeAttr("required").attr("nosend",""),0===s.length&&(s=$("").attr({name:"userlogin",size:t.length,id:"dlg_userlogin_sel",class:"form-control",required:""}).css({width:"100%","max-width":"100%",padding:"2px"}).insertAfter(r)),$.each(t,(function(t,e){var n=$("").attr({value:e.login,style:"padding-top: 2px; padding-bottom: 5px;","border-bottom":"1px solid #EEE;"}).text(e.name).appendTo(s);t%2==0&&n.css({"background-color":"#F9F9F9"})})),s.attr("required","").removeAttr("nosend")):(s.hide().attr("nosend",""),r.attr("required","").show(),o.attr("required","").removeAttr("nosend"),alert($t.l9))},error:function(t){$ocms.failure.call(this,t)},complete:function(){c.ldng(0)}})}else alert($t.l18)},sendpassword:function(t){var e=$(''),n=e.find(".form-body"),i=null;e.find("form").submit((function(t){t.preventDefault();var o=$(this).serializeObject(!0),r=null===i,s=r?"spwc":"spw";return $ocms.postXT.call(this,{url:$ocms.url(s),data:o,complete:function(){r?(n.append('
Ihnen wurde ein Code per SMS zugesandt.
Bitte tragen Sie den hier ein:
'),i=$('
').appendTo(n)):(alert($t.l13),e.remove())},error:()=>{}}),!1})),e.find(".modal-close").click((function(){e.remove()}));var o=[];$.each($t.l7a.split("\n"),((t,e)=>{Array.prototype.push.apply(o,[$("
"),$("").text(e)])})),e.find(".modal-note").append($('').text($t.alert)).append(o),e.appendTo("body"),setTimeout((function(){$(".modal").find('input[name="lastname"]').focus()}),600)}};var $$={s:function(t){return $("").text(t)},br:function(){return $("
")},sc:function(t,e){return $("").addClass(t).text(e)},td:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},th:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},tdc:function(t,e,n){return $$.td(e,n).addClass(t)},td2:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},td3:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},tdtr:function(t,e){var n=$$.tr().appendTo(e);return t instanceof jQuery==!0||"string"==typeof t?t.appendTo($$.td().appendTo(n)):!0===Array.isArray(t)&&$.each(t,(function(t,e){$(e).appendTo($$.td().appendTo(n))})),n},tr:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t&&n.attr(t),"object"==typeof e&&n.attr(e),n},trc:function(t,e){var n=$("").addClass(t);return e instanceof jQuery==!0?n.appendTo(e):"object"==typeof e&&n.attr(e),n},d:function(t){return $("
").attr(t||{})},dc:function(t,e,n,i){var o=$("
").addClass(t);return e instanceof jQuery==!0?o.appendTo(e):"object"==typeof e?o.attr(e):"function"==typeof e?o.click(e):"string"==typeof e&&o.text(e),"string"==typeof n?o.text(n):"object"==typeof n?o.attr(n):"function"==typeof n&&o.click(n),"string"==typeof i?o.text(i):"object"==typeof i?o.attr(i):"function"==typeof i&&o.click(i),o},df:function(t){return $("
 
").attr(t||{})},opt:function(t,e,n){var i=$("");return"string"==typeof t?i.attr("value",t):"object"==typeof t&&i.attr(t),"string"==typeof e?i.text(e):"object"==typeof e&&i.attr(e),"object"==typeof n&&i.attr(n),i},eOpt:function(t){var e=$('');return t&&e.attr("selected","selected"),e},tbl:function(t){return $("
").attr(t||{})},tblc:function(t){return $("
").addClass(t)},thead:function(t){let e=$("");return t instanceof jQuery&&e.prependTo(t),e},tbody:function(t){let e=$("");return t instanceof jQuery&&e.appendTo(t),e},tblset:function(t,e){let n=$$.tbl(t||{});return e instanceof jQuery&&e.append(n),{tbl:n,hd:$$.thead().appendTo(n),bdy:$$.tbody().appendTo(n)}},i:function(t){return $("").attr(t||{})},img:function(t,e){return $("").attr("src",t).attr(e||{})},sel:function(t){return $("").attr(t||{})},btn:function(t){return $("").attr(t||{})},a:function(t){return $("").attr(t||{})},li:function(t){return $("
  • ").attr(t||{})},ul:function(t){return $("
      ").attr(t||{})},nav:function(t){return $("").attr(t||{})},lbl:function(t,e){var n=$("");return"string"==typeof t&&n.text(t),"object"==typeof t?n.attr(t):"object"==typeof e&&n.attr(e),n},txt:function(t){return $("").attr(t||{})},0:function(t,e){return $("<"+t+">").attr(e||{})},bbtn:function(t,e){return $$.btn({type:"button",class:"btn"}).addClass(e).text(t)},svg:t=>$(document.createElementNS("http://www.w3.org/2000/svg",t))};function getMonday(t){var e=(t=new Date(t)).getDay(),n=t.getDate()-e+(0==e?-6:1);return new Date(t.setDate(n))}function $lf(t){var e=void 0===t?null:"number"==typeof t&&1!==t||"boolean"==typeof cl&&!1===t;return $("#listframe").tC("hd",e).is(".hd")}function $nuf(t){if(t&&t.stopPropagation(),!$(this).is(".disabled")){var e=function(t){t.removeClass("vis").find("li.dropdown").removeClass("open").removeClass("vis").attr("aria-expanded","false")},n=$(this).parent("li.dropdown");if(n.length>0){n.tC("open"),navs=!0===n.is(".open")?"true":"false",n.attr("aria-expanded",navs);var i=n.closest("nav");i.find("li.dropdown").not(n.parentsUntil("nav")).not(n).removeClass("open").attr("aria-expanded","false"),!1===n.is(".open")&&n.find("li.dropdown").removeClass("open").attr("aria-expanded","false"),e($("nav").not(i))}else e($("nav"))}}function $tbr(){return $lf(0),$("#topbar").ocmsmenu([])}function $lfr(){return $("#sidebar").empty(),$("#listframe").removeClass("fix").addClass("hd").empty()}function $cfr(){return $tbr(),$("#contentframe").empty()}function jObj(t,e){let n={};if("{"===(t||"").substr(0,1))try{n=JSON.parse(t)}catch(t){n={}}return n[e]||""}function string(t,e){var n,i=t||"";return $.each(e||[],(function(t,e){n=new RegExp("\\{"+t.toString()+"\\}","ig"),i=i.replace(n,e)})),i}function init_tooltip(t){var e=!0===("boolean"==typeof t&&t)&&"mouse";$("[title]").qtip({position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1}),$("div.tooltiptext").each((function(){$(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:$(this).clone()},position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden}})}))}String.prototype.trim=function(){return this.replace(/^\s+|\s+$/g,"")},String.prototype.left=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.slice(0,e):""}return this.substring(0,t)},String.prototype.right=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.substring(this.length-e):""}return this.substring(this.length-t)},Array.prototype.move=function(t,e){if(e>=this.length)for(var n=e-this.length;1+n--;)this.push(void 0);return this.splice(e,0,this.splice(t,1)[0]),this},function(t){t.fn.appendToIf=function(e,n){var i=t(this),o="function"==typeof n?n(i):n;return!0===("boolean"!=typeof o||o)&&i.appendTo(e),i},t.fn.appendIf=function(e,n){var i=t(this),o="function"==typeof n?n(i):n;return!0===("boolean"!=typeof o||o)&&i.append(e),i},t.fn.rwText=function(e,n,i){var o=t(this).empty();i=t.extend({wrap:!0},i);var r=!0===Array.isArray(e)?e:(null==e?"":String(e)).split("\n");return t.each(r,(function(t,e){""!==(e||"")&&(t>0&&o.append($$.br()),o.append(!0===i.wrap?$$.s(e):e))})),n&&o.attr("title",n),o},t.fn.loadSel=function(e,n,i){if("SELECT"===t(this).prop("tagName").toUpperCase()){var o=t(this);$ocms.postXT.call(this,{url:e,data:n||{},success:function(e){t.each(e,(function(){o.append($$.opt(e.value,e.text))}))},complete:function(){o.ldng(0),"function"==typeof i&&i.call(o)}})}},t.fn.emptyWithEditors=function(e){var n=t(this);return n.find(":input.tinymce").each((function(e,n){try{var i=tinymce.get(t(n).attr("id"));i&&i.remove()}catch(e){t.noop()}})),n.empty()},t.fn.cssValue=function(t){if(this.length>0){var e=this.css(t)||"";if(""===e)return 0;var n=/(^[\d\.]*)(\D{1,3}$)/gi.exec(e);return null!==n?"rem"===n[2]?$ocms.rpx(parseFloat(n[1])):parseFloat(n[1]):!1===isNaN(e)?parseFloat(e):0}return 0},t.fn.veryInnerHeight=function(){let e=e=>t(this).cssValue(e);return t(this).innerHeight()-e("padding-top")-e("padding-bottom")},t.fn.veryInnerWidth=function(){let e=e=>t(this).cssValue(e);return t(this).innerWidth()-e("padding-left")-e("padding-right")},t.fn.marginWidth=function(){let e=e=>t(this).cssValue(e);return e("margin-left")+e("margin-right")},t.fn.marginHeight=function(){let e=e=>t(this).cssValue(e);return e("margin-top")+e("margin-bottom")},t.inArrayRegEx=function(e,n,i){var o="regexp"===t.type(e)?e:new RegExp(e);if(!n)return-1;for(var r=i=i||0;r7){i=e.split(","),o=(n||(t<0?"rgb(0,0,0)":"rgb(255,255,255)")).split(",");var c=a(i[0].slice(4)),l=a(i[1]),u=a(i[2]);return"rgb("+(s((a(o[0].slice(4))-c)*r)+c)+","+(s((a(o[1])-l)*r)+l)+","+(s((a(o[2])-u)*r)+u)+")"}var d=(i=a(e.slice(1),16))>>16,h=i>>8&255,p=255&i;return"#"+(16777216+65536*(s((((o=a((n||(t<0?"#000000":"#FFFFFF")).slice(1),16))>>16)-d)*r)+d)+256*(s(((o>>8&255)-h)*r)+h)+(s(((255&o)-p)*r)+p)).toString(16).slice(1)},t.fn.IN=function(e){return t(this).fadeIn(400,e),t(this)},t.fn.OUT=function(e){return t(this).fadeOut(400,e),t(this)},t.fn.tooltip=function(e,n){var i=!0===("boolean"==typeof e&&e)&&"mouse",o="boolean"==typeof n&&n,r=t(this);return r.each((function(){var e=o?t(this).find(".tooltiptext"):t(this).children(".tooltiptext");t(e).length>0?e.each((function(){var e=t(this);t(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:e.clone()},position:{target:i,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},show:{effect:!1},hide:{effect:!1}}),e.remove()})):t(this).qtip({position:{target:i,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1})})),r},t.fn.rC=function(e){return t(this).removeClass(e)},t.fn.aC=function(e){return t(this).addClass(e)},t.fn.tC=function(e,n){return t(this).toggleClass(e,n)}}(jQuery),function(t){t.fn.ocmsmenu=function(e,n){var i=t(this);return $ocms.menu.call(i,e,n),i},t.fn.activatemenu=function(){var e=t(this).filter("nav");return e.find("a").not(".on").addClass("on").click($nuf),e.find(".nav-btn").not(".on").addClass("on").click((function(e){e.stopPropagation();var n=t(this);t(n.attr("data-target")).tC(n.attr("data-toggle"))})),e}}(jQuery);class ObjectArray extends Array{isEmpty(){return 0===this[0].length}static get[Symbol.species](){return Array}filter(t){return"function"==typeof t?new ObjectArray(this[0].filter(t)):this}remove(t){if("function"!=typeof t)return this;{let e=this[0].findIndex(t);for(;e>-1;)this[0].splice(e),e=this[0].findIndex(t)}}sortBy(t){return"function"==typeof t&&this[0].sort(t),this}sortString(t){return this[0].sort(((e,n)=>{let i=(e[t]||"").toString().toUpperCase(),o=(n[t]||"").toString().toUpperCase();return console.debug(i.localeCompare(o)),i.localeCompare(o)})),this}sortNum(t){return this[0].sort(((e,n)=>{let i=e[t],o=n[t];return!0===isNaN(o)&&!1===isNaN(i)||io?1:0})),this}sum(t){return this[0].reduce(((e,n)=>e+(!0===isNaN(n[t])?0:n[t])),0)}groupBy(t){return this[0].reduce((function(e,n){let i=n[t];return e[i]||(e[i]=[]),e[i].push(n),e}),{})}each(t){if("function"==typeof t){let e=!1;this[0].forEach(((n,i,o)=>{if(!1===e){let r=t(n,i,o);"boolean"==typeof r&&!1===r&&(e=!0)}}))}}get toArray(){return this[0]}}class NumArray extends Array{sum(){return this.reduce(((t,e)=>t+e))}first(){return this[0]}last(){return this[this.length-1]}average(){return this.sum()/this.length}range(){let t=this.map((t=>t)).sort();return{min:t[0],max:t[this.length-1]}}static get[Symbol.species](){return Array}}$ocms.ocmsmenu=[{lbl:"",id:"m_home",ico:"glyphicon glyphicon-home",fnc:"init:home"},{fnc:"separator"}],function(t){t.multline=function(t){let e=t.split("\n"),n=$$.d();return $.each(e,((t,e)=>{n.append($$.s(e))})),n.html()},t.tooltip_hidden=function(t,e){$(this).remove(),e.rendered=!1},t.isJSONDateString=function(t){return"string"==typeof t&&/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.test(t)},t.failure=function(e){11110===(e.internalCode||-1)?t.login.dlg():alert($t.f1+"\n"+(e.internalText||""))},t.getScript=function(e,n){var i=[],o=[],r=function(t){return"string"==typeof t&&""!==(t||"")},s=function(t,e){!0===bool(e.condition,!0)&&(""!==(e.script||"")&&o.push({url:e.script,module:e.module||""}),!0===r(e.css||"")?i.push(e.css):!0===Array.isArray(e.css)&&Array.prototype.push.apply(i,e.css.filter(r)))};!0===r(e||"")?o.push(e):!0===Array.isArray(e)?$.each(e,s):"object"==typeof e&&""!==(e.script||"")&&s(0,e);let a=[];$.each(i,(function(t,e){""!==(e||"")&&a.push(loadCSS(e))}));let c=o.map((function(e,n){let i=e.url,r=e.module||"";if(""===r){let t=new Promise((function(t,e){try{!async function(){$.ajax({url:i,dataType:"script",success:function(){t(o)},error:function(){e(o)},timeout:3e4})}()}catch(t){console.debug(t.message+"%o",t)}}));return t}return t.loadmodule(r,i,e.alias)}));Promise.all(c).then(n)},t.loadmodule=function(e,n,i){let o=new Promise((function(o,r){!async function(){try{let s=(n.startsWith("/")||n.startsWith(".")?"":"/")+n;import(s).then((n=>{t[e]=n[i||"default"],o(e)})).catch((t=>{console.debug(t.message+"%o",t),r(e)}))}catch(t){console.debug(t.message+"%o",t)}}()}));return o},t.ocms_auth=function(e,n,i,o){!1===$.isPlainObject(t.auth.modules)&&(t.auth.modules={});var r=0;t.auth.modules[e+(i||"")]?((r=t.auth.modules[e+(i||"")])<2&&(i||"")===auth.guid&&(r=2),r>=(n||0)&&o(false)):t.postXT({url:t.url("auth"),data:{module:e,person_guid:i||""},success:function(s){r=s[e],t.auth.modules[e+(i||"")]=r,r<2&&(i||"")===t.auth.person_guid&&(r=2),r>=(n||0)&&o(false)},error:function(e){t.failure.call(this,e)}})},t.auth.locale="de",t.ocms_prepauth=function(e,n,i){t.postXT({url:t.url("auth"),data:{fn:"csv",modules:e,person_guid:n||""},success:function(e){t.ocms_regauth(e)},error:function(e){t.failure.call(this,e)},complete:function(){i()}})},t.ocms_regauth=function(t){$.each(t||{},(function(t,e){auth.modules[t]=parseInt(e)}))},t.init=function(e){var n="string"==typeof e?e:(e.data||{}).fn||"";""!==n&&("home"===n?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),t.ov.call($("#contentframe"))):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),t.postXT({url:t.url(n+"/auth"),success:function(e){void 0===t[n]&&(t[n]={}),t[n].auth=e,e.manage>0&&t.getScript({module:n,script:["web/imdl",n,t.auth.locale||"de","js"].join("."),css:["web/imdl",n,"css"].join("."),condition:"function"!=typeof t[n].init2},(function(){t[n].init2()}))},error:function(){$("#contentframe").empty()}})))},t.menuarray=function(t){this.array=[],this.sep=function(){this.length>0&&"separator"!==this.array[array.length-1].fnc&&this.push({fnc:"separator"})},this.push=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.push.apply(this.array,t):"object"==typeof t&&this.array.push(t),t)},this.unshift=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.unshift.apply(this.array,t):"object"==typeof t&&this.array.unshift(t),t)},this.push(t)},t.menu=function(e,n){e=e||[];var i=$(this).removeClass("vis");if(!0===bool(n,!0)&&!1===i.is("#mainmenu")&&i.empty(),!1===bool(n,!1)&&i.is("#sidebar,#topbar")&&(e.unshift({id:"sbctrl",glyph:"glyphicon-th-list",aclass:"fbtn",fnc:function(){$lf()}}),$lf(0)),0===(e||[]).length)i.empty().addClass("hd");else{i.removeClass("hd");var o=!0===i.is("nav")?i:i.children("nav");1!==o.length&&(o=$("").tC("nv",i.is("#sidebar")).tC("ctxt",i.is("#topbar")).appendTo(i));var r,s=$$.ul().appendTo(o),a=function(t,e){var n=$(this).addClass("dropdown submenu");t.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"}),""!==(e.ico||"")&&t.prepend($$.sc("ico "+e.ico));var i=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(n);$.each(e.itm||[],(function(t,e){r.call(i,e)}))},c=function(t){$(this).tC("disabled","boolean"==typeof t.disabled?t.disabled:"string"==typeof t.disabled&&"subs"===t.disabled&&0===(t.itm||[]).length)};r=function(e){var n,i=$$.li({id:e.id}).attr(e.attr||{}).addClass(e.lclass).appendTo($(this)),o="string"==typeof e.fnc&&""!==e.fnc?e.fnc.split(":")[0]:"";""!==o&&"init"!==o?i.attr("role",o).appendIf($$.s(e.lbl),""!==ne(e.lbl)):(n=$$.a({class:"on",role:"button"}).addClass(e.aclass).appendTo(i).append($$.s(e.lbl)),c.call(n,e),(e.itm||[]).length>0&&a.call(i,n,e),n.click($nuf),"function"==typeof e.fnc?n.click(e.data||{},e.fnc):"init"===o&&n.click($.extend({},e.data||{},{fn:e.fnc.split(":")[1]}),t.init))},$.each(e,(function(e,n){var i,o=$$.li({id:n.id}).attr(n.attr||{}).addClass(n.lclass),a="string"==typeof n.fnc&&""!==n.fnc?n.fnc.split(":")[0]:"";if(""!==a&&"init"!==a)o.attr("role",a).appendIf($$.s(n.lbl),""!==ne(n.lbl));else{if(i=$$.a({class:"on",role:"button"}).addClass(n.aclass).appendTo(o),c.call(i,n),""!==(n.lbl||"")&&i.append($$.s(n.lbl)),""!==(n.ico||"")&&i.prepend($$.sc("ico "+n.ico)),""!==(n.glyph||"")&&i.prepend($$.sc("glyphicon "+n.glyph)),(n.itm||[]).length>0){o.addClass("dropdown"),i.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"});var l=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(o);$.each(n.itm||[],(function(t,e){r.call(l,e)}))}(n.sel||[]).length>0||(i.click($nuf),"function"==typeof n.fnc?i.click(n.data||{},n.fnc):"init"===a&&i.click($.extend({},n.data||{},{fn:n.fnc.split(":")[1]}),t.init))}o.appendTo(s)})),o.activatemenu()}},t.easytbl=(t,e)=>{e=e||{};let n=$$.tbl().addClass(e.class).css("border-collapse","collapse"),i=($$.tbody(n),!0===bool(e.frame,!1)?{padding:"5px",border:"1px solid #727272"}:{});if(!0===Array.isArray(e.header)){let t=$$.thead(n);$.each(e.header,((n,o)=>$$.th(t).css(e.cellcss||i).rwText(o)))}else if(!0===bool(e.header,!1)&&(t||[]).length>0){let o=$$.thead(n);$.each(Object.keys(t[0]),((t,n)=>$$.th(o).css(e.cellcss||i).rwText(n)))}return $.each(t||[],((t,o)=>{let r=$$.tr();$.each(o,((t,n)=>{n=n||"";let o=$$.td(r).css(e.cellcss||i);n instanceof jQuery?o.append(n):"string"==typeof n&&("<"===n.substring(0,1)?o.append(n):o.text(n))})),n.append(r)})),n},t.dlgtbl=(e,n,i)=>{i=i||{};let o=t.easytbl(e,i);t.dlg(o,$.extend({title:n},i))},t.dlg=function(t,n){n=n||{};let i=$("body > .modal").length>0,o=t=>typeof n[t],r=t=>"function"===o(t);if(!0===bool(n.exclusive,!0)&&!0===i)return void alert($t.dbldlg||"Es ist bereits ein Dialog geöffnet");let s=$$.dc("modal",$("body")),a=$$.dc("modal-dialog",s);!1===isNaN(n.zindex)?s.css("zIndex",n.zindex):!0===i&&s.css("zIndex",parseInt($("body > .modal:last").cssValue("zIndex"))+200),!1===isNaN(n.zindex_min)&&s.cssValue("zIndex")').appendTo(d)),""!==ne(n.title)&&(c=$$.dc("modal-header",d),$("

      ").text(n.title).appendTo(c));let p=$$.dc("modal-body",d),f=$$.dc("modal-footer",d);t instanceof jQuery==!0&&p.append(t);let m=function(t){t&&"function"==typeof t.stopPropagation&&t.stopPropagation(),a.removeClass("in"),!0===r("closing")&&n.closing.call(d),p.hide().emptyWithEditors(),s.remove(),!0===r("close")&&n.close.call(d)};if(d.find(":input[required]").length>0&&($$.dc("note_required",f).append($$.sc("ind_required","*")).append($$.s($t.t1||"Eingabe erforderlich")),$$.dc("note_invalid",f).append($$.s($t.t2||"Bitte überprüfen Sie Ihre Eingaben im Formular."))),!0===r("cancel")){$$.bbtn(n.cancelbutton||"Abbrechen","cancel").attr({type:"button",role:"cancel"}).appendTo(f).click((function(t){n.cancel.call(d,t);t.stopPropagation(),m()}))}if(!0===r("confirm")){let t=$$.bbtn(n.button||"OK","confirm").attr({type:!0===bool(n.form,!1)?"submit":"button",role:"confirm"}).appendTo(f);!0===h?(d.submit((function(t){try{n.confirm.call(d,t)}finally{t.preventDefault()}return!1})),d.on("modal_submit",(function(){n.confirm.call(d,e)}))):(t.click((function(t){n.confirm.call(d,t);t.stopPropagation()})),d.on("modal_submit",(function(){t.click()})))}else!0===h&&d.submit((function(t){return t.preventDefault(),!1}));return d.on("modal_close",(function(){m()})),l.click(m),!0===r("opening")&&n.opening.call(d),a.addClass("in"),ne(n.mode).indexOf("maxbody")>-1&&p.css("min-height",(u.height()-c.outerHeight()-f.outerHeight()).toString()+"px"),!0===r("open")&&n.open.call(d),{hd:c,bdy:p,ft:f,ct:u,dlg:a,c:d}},t.mform=function(e){let n=$$.dc("form-body"),i=Array.isArray(e)?e:e instanceof fields_definition?e.fields:[];return $.each(i||[],(function(e,i){let o=i.type||"";if("ignore"===o)return!0;let r=$$.dc("form-group",n),s=i.id||"dlg_"+(i.name||"")+("html"===i.type?"_"+(65536*(1+Math.random())||0).toString(16).substr(9):""),a=$$.lbl(i.label||i.name,{for:s}).appendTo($$.dc("form-itm",r)),c=$$.dc("form-itm",r),l=$$.i({id:s,name:i.name,placeholder:i.placeholder,type:i.type});switch(o){case"email":i.pattern=ne(i.pattern,"[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$");break;case"url":i.pattern=ne(i.pattern,"https?://.+");break;case"number":i.pattern=ne(i.pattern,"[-+]?[0-9]*[.,]?[0-9]*"),l.attr("type","text"),l.attr("inputmode","decimal"),l.attr("step",i.precision||"any"),l.attr("data-format","float");break;case"integer":case"int":i.pattern=ne(i.pattern,"[-+]?[0-9]*"),l.attr("type","number"),l.attr("data-format","integer");break;case"date":if(""!==ne(i.pattern,$t.datepattern)&&(i.pattern=ne(i.pattern,"("+$t.datepattern+")|([0-9]{4}.(0[1-9]|1[012]).(0[1-9]|1[0-9]|2[0-9]|3[01]))")),""!==ne(i.placeholder,$t.dateplaceholder)&&l.attr("placeholder",ne(i.placeholder,$t.dateplaceholder)),"string"==typeof i.value){var u=i.value.substr(0,10);i.value="date"!==l.prop("type")?fdt(u+"T00:00:00",ne(i.dateformat,$t.dateformat)):u}l.attr("data-format","date:"+ne(i.dateformat,$t.dateformat)+";yyyy-MM-dd");break;case"datetime":l.attr("type","datetime-local"),""!==ne(i.pattern,$t.datetimepattern)&&(i.pattern=ne(i.pattern,"("+$t.datetimepattern+")|([0-9]{4}-(0[1-9]|1[012])-(0[1-9]|1[0-9]|2[0-9]|3[01])\\s([0-5][0-9]):([0-5][0-9]))")),""!==ne(i.placeholder,$t.datetimeplaceholder)&&l.attr("placeholder",ne(i.placeholder,$t.datetimeplaceholder)),"string"==typeof i.value&&"T"===i.value.substr(10,1)&&(i.value="datetime"!==l.prop("type").substr(0,8)?fdt(i.value,ne(i.datetimeformat,$t.datetimeformat)):i.value),l.attr("data-format","datetime:"+ne(i.datetimeformat,$t.datetimeformat)+";yyyy-MM-dd HH:mm:ss");break;case"hidden":r.addClass("hd");break;case"html":case"text":l=$$.txt({id:s,name:i.name,placeholder:i.placeholder,type:i.type}),l.tC("tinymce","html"===i.type);break;case"bool":case"boolean":i.url=[{value:"true",label:($t||{}).true||"Yes"},{value:"false",label:($t||{}).false||"No"}],"boolean"==typeof i.value&&(i.value=i.value?"true":"false");case"select":l=$$.sel({id:s,name:i.name,type:i.type}),!1===bool(i.required,!1)&&$$.eOpt().appendTo(l);try{var d=function(t){!0===Array.isArray(t)&&$.each(t,(function(t,e){"string"==typeof e?$$.opt(e,e).appendTo(l):!0===Array.isArray(e)?$$.opt(e[0],e[1]).appendTo(l):"object"==typeof e&&$$.opt(e.value,e.label||e.text).appendTo(l)}))};!0===Array.isArray(i.url)?d(i.url):"function"==typeof i.url?i.url.call(l):"string"==typeof i.url&&t.postXT({url:i.url,success:d})}catch(t){$.noop()}break;default:""!==ne(i["max-length"])&&l.attr("max-length",i["max-length"])}""!==ne(i.pattern)&&l.attr("pattern",i.pattern),l.val(i.value).change(),l.change((function(){$(this)[0].setCustomValidity("")})),l.addClass("form-control").prop("required",bool(i.required,!1)).prop("readonly",bool(i.readonly,!1)).appendTo(c),!0===bool(i.required,!1)&&a.append($$.sc("ind_required","*")),"object"==typeof i.attr&&l.attr(i.attr),"object"==typeof i.prop&&l.prop(i.prop),"string"==typeof i.class&&l.addClass(i.class),"function"==typeof i.change&&(l.change(i.change),!0===bool(i.applychange,!1)&&void 0!==i.value&&l.change()),""!==(i.note||"")&&$$.dc("form-note",c).rwText(i.note),"function"==typeof i.complete&&i.complete.call(l)})),n},t.initMCE=function(t,e){t=$(t),e=e||{};try{let n={target:t[0],inline:!1,width:e.width||"100%",statusbar:!1,document_base_url:window.location.origin+"/",content_style:"ph:before {content: '«'; color: #BBB; font-style:italic; } ph:after {content: '»'; color: #BBB; font-style:italic; } ph { color: #AAA; font-style:italic; }",relative_urls:!1,remove_script_host:!1};!0===bool(e.hidemenu,!1)&&(n.menubar=!1,n.menu={}),!0===bool(e.hidetoolbar,!1)&&(n.toolbar=!1),$.extend(n,e||{}),tinymce.init(n)}catch(t){alert(t.message)}},t.dlgform=function(e,n){n=n||{};let i,o=$$.dc("frm").append(t.mform(e||[]).addClass("stacked"));n.addcontent instanceof jQuery&&o.append(n.addcontent),"function"==typeof n.submit?i=n.submit:"function"==typeof n.success&&(i=function(e){var i=$(this).ldng(1),o=$.extend({loginaccount:t.auth.account||""},i.serializeObject(bool(n.checkvalidity,!0),{typedvalues:bool(n.typedvalues,!1)}));""!==(n.url||"")?t.postXT({url:n.url,data:o,success:function(t){n.success.call(this,t),i.trigger("modal_close")},error:function(){alert($t.l17)},complete:function(){i.ldng(0)},timeout:6e4}):(n.success.call(this,o),i.trigger("modal_close"))});let r={form:!0,title:n.title||"",button:n.button||$t.submit,confirm:i,size:n.size||[500,600],open:function(){let e=$(this).find(".tinymce");e.length>0&&t.initMCE(e,n.tinymce||{})}};return t.dlg.call(this,o,r)},t.login.dlg=function(e){e=e||{};let n=[{name:"userinfo",label:$t.l1,type:"string",value:t.auth.login,change:t.login.uichange,required:!0},{name:"userlogin",type:"hidden",required:!0,value:t.auth.login},{name:"username",type:"string",label:$t.l4,required:!0,readonly:!0,placeholder:$t.l5,value:t.auth.fullname_rev},{name:"userpass",type:"password",label:$t.l3,required:!0,placeholder:$t.l3}];""===(t.auth.account||"")&&n.unshift({id:"dlg_loginaccount",name:"loginaccount",type:"string",required:!0,value:t.auth.account});let i=$$.dc("frm").append(t.mform(n).addClass("stacked")),o=t.dlg.call(this,i,{form:!0,title:$t.l0,button:$t.submit,confirm:function(n){var i=$(this).ldng(1),o=$.extend({loginaccount:t.auth.account||""},i.serializeObject());t.postXT({url:"/vt/login",data:o,success:function(n){""!==((n||{}).login||"")&&(i.trigger("modal_close"),t.auth=n,"object"==typeof e.ajo&&(e.ajo.islogin,$.ajax(e.ajo)))},error:function(){alert($t.l17)},complete:function(){i.ldng(0)},timeout:6e4})},size:[500,600]}),r=$$.dc("modal-content").css("height","auto").attr("novalidate","true").append($$.dc("modal-header").appendIf($("

      ").text(t.auth.accountname),""!==(t.auth.accountname||"")).append($("

      Vereinsmanager

      ")));o.dlg.prepend(r)},t.addNoEntryInfo=function(t){$(this).append($$.dc("noentryinfo").text(t||$t.t11))}}($ocms),Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(t){var e=this;do{if(Element.prototype.matches.call(e,t))return e;e=e.parentElement||e.parentNode}while(null!==e&&1===e.nodeType);return null}),function(t,e){var n,i;"object"==typeof window&&(window[t]=(n=function(t){var e=window,n=document.body,i=document.documentElement,o=Math.max(0,e.pageXOffset||i.scrollLeft||n.scrollLeft||0)-(i.clientLeft||0),r=Math.max(0,e.pageYOffset||i.scrollTop||n.scrollTop||0)-(i.clientTop||0);return{x:t?Math.max(0,t.pageX||t.clientX||0)-o:0,y:t?Math.max(0,t.pageY||t.clientY||0)-r:0}},(i=function(t,e){t&&t instanceof Element&&(this._container=t,this._options=e||{},this._clickItem=null,this._dragItem=null,this._showDragItem="boolean"!=typeof this._options.dragItem||!1!==this._options.dragItem,this._hovItem=null,this._sortLists=[],this._click={},this._dragging=!1,this._dragHandleClass=this._options.dragHandleClass||"",this._parentident=this._options.parentident||"",this._swapdone="function"==typeof this._options.swapdone?this._options.swapdone:null,this._onend="function"==typeof this._options.onend?this._options.onend:null,this._container.setAttribute("data-is-sortable",1),this._container.classList.add("sortable"),this._container.style.position="static",window.addEventListener("mousedown",this._onPress.bind(this),!0),window.addEventListener("touchstart",this._onPress.bind(this),!0),window.addEventListener("mouseup",this._onRelease.bind(this),!0),window.addEventListener("touchend",this._onRelease.bind(this),!0),window.addEventListener("mousemove",this._onMove.bind(this),!0),window.addEventListener("touchmove",this._onMove.bind(this),!0))}).prototype={constructor:i,toArray:function(t){t=t||"id";for(var e=[],n="",i=0;ii.left&&ei.top&&n0?a.mousedown(c).addClass("dctrl"):s.mousedown(c).addClass("dctrl"),t(this)}}(jQuery),$(document).ready((function(){$("html").click((function(t){$nuf()})),$("#listframe").click((function(t){t.stopPropagation(),$nuf()})),$("#mainmenu").ocmsmenu($ocms.ocmsmenu),$("#mainmenu").activatemenu()})),$.extend($t,{m_inv:"Rechnungen",m_req:"Aufträge",m_rep:"Berichte",m_todo:"ToDos",m_bcd:"BankBuchungen",m_admin:"Administration",m_admin_status:"Systemüberwachung",m_usr:"Benutzerverwaltung",m_usrgrp:"Gruppenverwaltung",rsp:"Passwort ändern",pnm:"Die Passwörter stimmen nicht überein",cps:"Das neue Passwort wurde gespeichert.",pwr:"Bitte wählen Sie ein starkes Passwort (min 8 Zeichen, davon jeweils min 2 Zahlen, kleine und große Buchstaben, Sonderzeichen sind optional).",smsc:"Sie beötigen für diese Funktion einen SMS-Code.\nSoll dieser nun versandt werden?",wdc:"Doppelt klicken, um die Box zu aktualisieren.",wldg:"Widgets werden geladen…",wdg:{}}),$t.rspf={sms:"Der SMS-Code konnte nicht bestätigt werden",valid:"Das alte Passwort ist nicht korrekt",requirements:"Das Passwort entspricht nicht den Anforderungen.\n"+$t.pwr},$fd={rsp:new fields_definition("","",[{name:"opw",label:"aktuelles Passwort",type:"password",required:!0,attr:{"auto-complete":"current-password"}},{name:"npw",label:"neues Passwort",type:"password",required:!0,pattern:"(.{6,})",attr:{"auto-complete":"new-password"}},{name:"npwc",label:"neues Passwort (Bestätigung)",type:"password",required:!0,attr:{"auto-complete":"new-password"},note:$t.pwr},{name:"code",label:"SMS-Code",type:"string",required:!0,attr:{"auto-complete":"one-time-code"}}])},$ocms.init=function(t){var e="string"==typeof t?t:(t.data||{}).fn||"";""!==e&&("home"===e?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),$fis.ov()):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),$ocms.postXT({url:$ocms.url(e+"/auth"),success:function(t){if(void 0===$ocms[e]&&($ocms[e]={}),$ocms[e].auth=t,t.manage>0){let t="?v="+encodeURIComponent($ocms.auth.assetversion||"0");$ocms.getScript({module:e,script:["/web/fis",e,$ocms.auth.locale||"de","js"].join(".")+t,css:["/web/fis",e,"css"].join(".")+t,condition:"function"!=typeof $ocms[e].init2},(function(){$ocms[e].init2()}))}},error:function(){$("#contentframe").empty()}})))};var $fis={auth:{},db:function(){$("#mainmenu_activemodule").text($t.ov);let t=$(this).empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){$.each(t,(function(t,n){var i=$$.dc("wdg_frame",e,{"data-wdg":n}).hide();$ocms.wdg.call(i,{wdg:n})}))},loading:e})},ValidateEmail:function(t){return!!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test(t)},cf:t=>{let e=$("#contentframe");return!0===bool(t,!1)&&e.empty().rC("hd"),e},lf:t=>{let e=$("#listframe");return!0===bool(t,!1)&&e.empty().aC("hd").rC("fix"),e},frm_edit:function(t){let e=$fis.cf(!1),n=e.children(".cfrm"),i=e.children(".edit_frm");return n.length<1?n=$$.dc("cfrm hd").prependTo(e):!0===bool(t,!1)&&n.empty(),i.length<1&&(i=$$.dc("edit_frm").insertAfter(n)),i.empty()},frm_list:function(t,e){let n=$fis.cf(!1),i=n.children(".cfrm"),o=n.children(".list_frm");return i.length<1?i=$$.dc("cfrm hd").prependTo(n):!0===bool(t,!1)&&i.empty(),!0===bool(e,!1)&&n.children(".edit_frm").remove(),o.length<1&&(o=$$.dc("list_frm").appendTo(n)),o.empty()},lfm:()=>{let t=$fis.lf(!1),e=t.children(".lfrm");return e.length<1&&(e=$$.dc("lfrm").prependTo(t)),e},getAuth:(t,e)=>new Promise(((n,i)=>{$fis.auth[t]&&!1===bool(e,!1)?n($fis.auth[t]||-1):$ocms.postXT({url:$ocms.url("auth"),data:{module:t},success:e=>{$fis.auth[t]=e.auth||-1,n($fis.auth[t]||-1)},error:()=>{i()}})})),prepAuth:t=>new Promise(((e,n)=>{$ocms.postXT({url:$ocms.url("auth"),data:{module:t,array:1},success:t=>{$.extend($fis.auth,t||{})},complete:()=>{e()}})})),isAuth:(t,e)=>($fis.auth[t]||-1)>=(e||1),resetPass:function(t,e){confirm($t.smsc)&&($ocms.postXT({url:$ocms.url("account/sms"),data:{fn:"pwc"}}),$ocms.dlgform($fd.rsp.clone(),{title:$t.rsp||"",submit:function(t){var e=$(this).ldng(1),n=$.extend({loginaccount:$ocms.auth.account||""},e.serializeObject(!0,{typedvalues:!0}));(n.npw||"")!==(n.npwc||"")?e.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm):$ocms.postXT({url:$ocms.url("account/changepassword"),data:n,success:function(t){alert($t.cps),e.trigger("modal_close")},error:function(t){alert($t.rspf[t.getResponseHeader("x-ocms-std")])},complete:function(){e.ldng(0)},timeout:6e4})}}))},wdg:function(t){let e=$(this).empty();$ocms.postXT({url:$ocms.url("wdg/one"),data:{short_name:t.wdg},timeout:9e4,success:function(n,i,o){let r=t.wdg,s=n[r];if(!s)return void e.ldng(0);let a=$.inArrayRegEx("dblwidth",s.rendering_options)>-1,c=$.inArrayRegEx("tiny",s.rendering_options)>-1;e.toggleClass("dbl",a&&!c).toggleClass("tny",c);$$.dc("wdg_hd",e,{title:ne(s.description,$t.wdc)}).toggleClass("dbl",a).text(ne(s.name,t.wdg)).dblclick((function(t){t.stopPropagation(),$fis.wdg.call(e,{wdg:r})}));let l=$$.dc("wdg_cnt",e).toggleClass("dbl",a).hide(),u=$.inArrayRegEx("bgcolor",s.rendering_options);switch(u>-1&&l.css("backgroundColor",s.rendering_options[u].toString().right(":")),s.type){case"table":var d=$$.tblset({},l),h=$$.tr().appendTo(d.hd),p=$t.wdg[r.indexOf("wdg_ev_")>=0?"wdg_ev_":r]||{};$.each(s.columns,(function(t,e){var n=p[e]?p[e].label:e;$$.th().text(n).appendTo(h)})),$.each(s.data,(function(t,e){var n=$$.tr().appendTo(d.bdy);$.each(s.columns,(function(t,i){var o=$$.td().appendTo(n);e[i]instanceof Date||!0===$ocms.isJSONDateString(e[i])?o.text(fdt(e[i],$t.dateformat)):o.rwText(e[i])}))})),$.inArray("firstrow_bold",s.rendering_options)>-1&&h.nextAll("tr:first").css("font-weight","bold");break;case"ind":$$.dc("ind",l).addClass("sts_"+(s.data.status||"")).append([$$.dc("ind").text(s.data.value),$$.lbl(s.data.label)]);break;case"image_url":l.css("background","url('"+s.url+"') no-repeat center center transparent");break;case"image_base64":l.css("background","url('data:image/png;base64,"+s.image+"') no-repeat center center transparent");break;case"html":if(l.html(s.html),$.inArray("reload_10min",s.rendering_options)>-1){var f=l.find("iframe");setTimeout((function(){f.attr("src",(function(t,e){return e}))}),6e5)}}$.inArray("reload_30min",s.rendering_options)>-1&&"html"!==s.type&&setTimeout((function(){$fis.wdg.call(e,{wdg:r})}),18e5),e.is(":visible")?l.slideDown(150):(l.show(),e.fadeIn(150))},error:function(t){e.is(":visible")&&e.slideUp(150),$fis.failure.call(this,t)},complete:function(){e.ldng(0).trigger("wdg:settled")}})},ov:function(){$fis.lf(!0);let t=$("#contentframe").empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){let n=$$.dc("wdg_loading_ind",e).text($t.wldg||"").hide(),i=t.length,o=function(){--i<=0&&n.fadeOut(150)};i>0&&n.show(),$.each(t,(function(t,n){var i=$$.dc("wdg_frame",e,{"data-wdg":n}).hide().one("wdg:settled",o);$fis.wdg.call(i,{wdg:n})}))},loading:e})}};$fis.notifications={connection:null,init:function(){"undefined"!=typeof signalR&&null===this.connection&&$ocms.auth.useraccount_id&&(this.ensureFrame(),this.connection=(new signalR.HubConnectionBuilder).withUrl("/notifications").withAutomaticReconnect().build(),this.connection.on("notification",(t=>{this.push(t)})),this.connection.onclose((()=>{console.warn("Notification connection closed; retrying in 5s."),this.connection=null,setTimeout((()=>this.init()),5e3)})),this.start())},start:function(){this.connection.start().catch((t=>{console.warn("Notification connection failed to start; retrying in 5s.",t),this.connection=null,setTimeout((()=>this.init()),5e3)}))},ensureFrame:function(){$("#notification_frame").length<1&&$("
      ",{id:"notification_frame"}).appendTo($("footer:first").length?"footer:first":"body")},push:function(t){this.ensureFrame(),t=t||{};let e=$("
      ",{class:"notification_item"}).addClass((t.severity||"info").toLowerCase()).append($("
      '),n=e.find(".form-body"),i=null;e.find("form").submit((function(t){t.preventDefault();var o=$(this).serializeObject(!0),r=null===i,s=r?"spwc":"spw";return $ocms.postXT.call(this,{url:$ocms.url(s),data:o,complete:function(){r?(n.append('
      Ihnen wurde ein Code per SMS zugesandt.
      Bitte tragen Sie den hier ein:
      '),i=$('
      ').appendTo(n)):(alert($t.l13),e.remove())},error:()=>{}}),!1})),e.find(".modal-close").click((function(){e.remove()}));var o=[];$.each($t.l7a.split("\n"),((t,e)=>{Array.prototype.push.apply(o,[$("
      "),$("").text(e)])})),e.find(".modal-note").append($('').text($t.alert)).append(o),e.appendTo("body"),setTimeout((function(){$(".modal").find('input[name="lastname"]').focus()}),600)}};var $$={s:function(t){return $("").text(t)},br:function(){return $("
      ")},sc:function(t,e){return $("").addClass(t).text(e)},td:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},th:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t?n.attr(t):"string"==typeof t&&n.text(t),"object"==typeof e?n.attr(e):"string"==typeof e&&n.text(e),n},tdc:function(t,e,n){return $$.td(e,n).addClass(t)},td2:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},td3:function(t){var e=$('');return"string"===$.type(t)?e.text(t):t instanceof jQuery?e.append(t):"function"==typeof t?t.call(e):e.html(" "),e},tdtr:function(t,e){var n=$$.tr().appendTo(e);return t instanceof jQuery==!0||"string"==typeof t?t.appendTo($$.td().appendTo(n)):!0===Array.isArray(t)&&$.each(t,(function(t,e){$(e).appendTo($$.td().appendTo(n))})),n},tr:function(t,e){var n=$("");return t instanceof jQuery==!0?n.appendTo(t):"object"==typeof t&&n.attr(t),"object"==typeof e&&n.attr(e),n},trc:function(t,e){var n=$("").addClass(t);return e instanceof jQuery==!0?n.appendTo(e):"object"==typeof e&&n.attr(e),n},d:function(t){return $("
      ").attr(t||{})},dc:function(t,e,n,i){var o=$("
      ").addClass(t);return e instanceof jQuery==!0?o.appendTo(e):"object"==typeof e?o.attr(e):"function"==typeof e?o.click(e):"string"==typeof e&&o.text(e),"string"==typeof n?o.text(n):"object"==typeof n?o.attr(n):"function"==typeof n&&o.click(n),"string"==typeof i?o.text(i):"object"==typeof i?o.attr(i):"function"==typeof i&&o.click(i),o},df:function(t){return $("
       
      ").attr(t||{})},opt:function(t,e,n){var i=$("");return"string"==typeof t?i.attr("value",t):"object"==typeof t&&i.attr(t),"string"==typeof e?i.text(e):"object"==typeof e&&i.attr(e),"object"==typeof n&&i.attr(n),i},eOpt:function(t){var e=$('');return t&&e.attr("selected","selected"),e},tbl:function(t){return $("
      ").attr(t||{})},tblc:function(t){return $("
      ").addClass(t)},thead:function(t){let e=$("");return t instanceof jQuery&&e.prependTo(t),e},tbody:function(t){let e=$("");return t instanceof jQuery&&e.appendTo(t),e},tblset:function(t,e){let n=$$.tbl(t||{});return e instanceof jQuery&&e.append(n),{tbl:n,hd:$$.thead().appendTo(n),bdy:$$.tbody().appendTo(n)}},i:function(t){return $("").attr(t||{})},img:function(t,e){return $("").attr("src",t).attr(e||{})},sel:function(t){return $("").attr(t||{})},btn:function(t){return $("").attr(t||{})},a:function(t){return $("").attr(t||{})},li:function(t){return $("
    • ").attr(t||{})},ul:function(t){return $("
        ").attr(t||{})},nav:function(t){return $("").attr(t||{})},lbl:function(t,e){var n=$("");return"string"==typeof t&&n.text(t),"object"==typeof t?n.attr(t):"object"==typeof e&&n.attr(e),n},txt:function(t){return $("").attr(t||{})},0:function(t,e){return $("<"+t+">").attr(e||{})},bbtn:function(t,e){return $$.btn({type:"button",class:"btn"}).addClass(e).text(t)},svg:t=>$(document.createElementNS("http://www.w3.org/2000/svg",t))};function getMonday(t){var e=(t=new Date(t)).getDay(),n=t.getDate()-e+(0==e?-6:1);return new Date(t.setDate(n))}function $lf(t){var e=void 0===t?null:"number"==typeof t&&1!==t||"boolean"==typeof cl&&!1===t;return $("#listframe").tC("hd",e).is(".hd")}function $nuf(t){if(t&&t.stopPropagation(),!$(this).is(".disabled")){var e=function(t){t.removeClass("vis").find("li.dropdown").removeClass("open").removeClass("vis").attr("aria-expanded","false")},n=$(this).parent("li.dropdown");if(n.length>0){n.tC("open"),navs=!0===n.is(".open")?"true":"false",n.attr("aria-expanded",navs);var i=n.closest("nav");i.find("li.dropdown").not(n.parentsUntil("nav")).not(n).removeClass("open").attr("aria-expanded","false"),!1===n.is(".open")&&n.find("li.dropdown").removeClass("open").attr("aria-expanded","false"),e($("nav").not(i))}else e($("nav"))}}function $tbr(){return $lf(0),$("#topbar").ocmsmenu([])}function $lfr(){return $("#sidebar").empty(),$("#listframe").removeClass("fix").addClass("hd").empty()}function $cfr(){return $tbr(),$("#contentframe").empty()}function jObj(t,e){let n={};if("{"===(t||"").substr(0,1))try{n=JSON.parse(t)}catch(t){n={}}return n[e]||""}function string(t,e){var n,i=t||"";return $.each(e||[],(function(t,e){n=new RegExp("\\{"+t.toString()+"\\}","ig"),i=i.replace(n,e)})),i}function init_tooltip(t){var e=!0===("boolean"==typeof t&&t)&&"mouse";$("[title]").qtip({position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1}),$("div.tooltiptext").each((function(){$(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:$(this).clone()},position:{target:e,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden}})}))}String.prototype.trim=function(){return this.replace(/^\s+|\s+$/g,"")},String.prototype.left=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.slice(0,e):""}return this.substring(0,t)},String.prototype.right=function(t){if("string"===$.type(t)){var e=this.indexOf(t);return e>0?this.substring(this.length-e):""}return this.substring(this.length-t)},Array.prototype.move=function(t,e){if(e>=this.length)for(var n=e-this.length;1+n--;)this.push(void 0);return this.splice(e,0,this.splice(t,1)[0]),this},function(t){t.fn.appendToIf=function(e,n){var i=t(this),o="function"==typeof n?n(i):n;return!0===("boolean"!=typeof o||o)&&i.appendTo(e),i},t.fn.appendIf=function(e,n){var i=t(this),o="function"==typeof n?n(i):n;return!0===("boolean"!=typeof o||o)&&i.append(e),i},t.fn.rwText=function(e,n,i){var o=t(this).empty();i=t.extend({wrap:!0},i);var r=!0===Array.isArray(e)?e:(null==e?"":String(e)).split("\n");return t.each(r,(function(t,e){""!==(e||"")&&(t>0&&o.append($$.br()),o.append(!0===i.wrap?$$.s(e):e))})),n&&o.attr("title",n),o},t.fn.loadSel=function(e,n,i){if("SELECT"===t(this).prop("tagName").toUpperCase()){var o=t(this);$ocms.postXT.call(this,{url:e,data:n||{},success:function(e){t.each(e,(function(){o.append($$.opt(e.value,e.text))}))},complete:function(){o.ldng(0),"function"==typeof i&&i.call(o)}})}},t.fn.emptyWithEditors=function(e){var n=t(this);return n.find(":input.tinymce").each((function(e,n){try{var i=tinymce.get(t(n).attr("id"));i&&i.remove()}catch(e){t.noop()}})),n.empty()},t.fn.cssValue=function(t){if(this.length>0){var e=this.css(t)||"";if(""===e)return 0;var n=/(^[\d\.]*)(\D{1,3}$)/gi.exec(e);return null!==n?"rem"===n[2]?$ocms.rpx(parseFloat(n[1])):parseFloat(n[1]):!1===isNaN(e)?parseFloat(e):0}return 0},t.fn.veryInnerHeight=function(){let e=e=>t(this).cssValue(e);return t(this).innerHeight()-e("padding-top")-e("padding-bottom")},t.fn.veryInnerWidth=function(){let e=e=>t(this).cssValue(e);return t(this).innerWidth()-e("padding-left")-e("padding-right")},t.fn.marginWidth=function(){let e=e=>t(this).cssValue(e);return e("margin-left")+e("margin-right")},t.fn.marginHeight=function(){let e=e=>t(this).cssValue(e);return e("margin-top")+e("margin-bottom")},t.inArrayRegEx=function(e,n,i){var o="regexp"===t.type(e)?e:new RegExp(e);if(!n)return-1;for(var r=i=i||0;r7){i=e.split(","),o=(n||(t<0?"rgb(0,0,0)":"rgb(255,255,255)")).split(",");var c=a(i[0].slice(4)),l=a(i[1]),u=a(i[2]);return"rgb("+(s((a(o[0].slice(4))-c)*r)+c)+","+(s((a(o[1])-l)*r)+l)+","+(s((a(o[2])-u)*r)+u)+")"}var d=(i=a(e.slice(1),16))>>16,h=i>>8&255,p=255&i;return"#"+(16777216+65536*(s((((o=a((n||(t<0?"#000000":"#FFFFFF")).slice(1),16))>>16)-d)*r)+d)+256*(s(((o>>8&255)-h)*r)+h)+(s(((255&o)-p)*r)+p)).toString(16).slice(1)},t.fn.IN=function(e){return t(this).fadeIn(400,e),t(this)},t.fn.OUT=function(e){return t(this).fadeOut(400,e),t(this)},t.fn.tooltip=function(e,n){var i=!0===("boolean"==typeof e&&e)&&"mouse",o="boolean"==typeof n&&n,r=t(this);return r.each((function(){var e=o?t(this).find(".tooltiptext"):t(this).children(".tooltiptext");t(e).length>0?e.each((function(){var e=t(this);t(this).filter(":not(:empty)").parent().qtip({suppress:!1,content:{text:e.clone()},position:{target:i,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},show:{effect:!1},hide:{effect:!1}}),e.remove()})):t(this).qtip({position:{target:i,adjust:{x:2,y:2},viewport:!0},events:{hidden:$ocms.tooltip_hidden},effect:!1})})),r},t.fn.rC=function(e){return t(this).removeClass(e)},t.fn.aC=function(e){return t(this).addClass(e)},t.fn.tC=function(e,n){return t(this).toggleClass(e,n)}}(jQuery),function(t){t.fn.ocmsmenu=function(e,n){var i=t(this);return $ocms.menu.call(i,e,n),i},t.fn.activatemenu=function(){var e=t(this).filter("nav");return e.find("a").not(".on").addClass("on").click($nuf),e.find(".nav-btn").not(".on").addClass("on").click((function(e){e.stopPropagation();var n=t(this);t(n.attr("data-target")).tC(n.attr("data-toggle"))})),e}}(jQuery);class ObjectArray extends Array{isEmpty(){return 0===this[0].length}static get[Symbol.species](){return Array}filter(t){return"function"==typeof t?new ObjectArray(this[0].filter(t)):this}remove(t){if("function"!=typeof t)return this;{let e=this[0].findIndex(t);for(;e>-1;)this[0].splice(e),e=this[0].findIndex(t)}}sortBy(t){return"function"==typeof t&&this[0].sort(t),this}sortString(t){return this[0].sort(((e,n)=>{let i=(e[t]||"").toString().toUpperCase(),o=(n[t]||"").toString().toUpperCase();return console.debug(i.localeCompare(o)),i.localeCompare(o)})),this}sortNum(t){return this[0].sort(((e,n)=>{let i=e[t],o=n[t];return!0===isNaN(o)&&!1===isNaN(i)||io?1:0})),this}sum(t){return this[0].reduce(((e,n)=>e+(!0===isNaN(n[t])?0:n[t])),0)}groupBy(t){return this[0].reduce((function(e,n){let i=n[t];return e[i]||(e[i]=[]),e[i].push(n),e}),{})}each(t){if("function"==typeof t){let e=!1;this[0].forEach(((n,i,o)=>{if(!1===e){let r=t(n,i,o);"boolean"==typeof r&&!1===r&&(e=!0)}}))}}get toArray(){return this[0]}}class NumArray extends Array{sum(){return this.reduce(((t,e)=>t+e))}first(){return this[0]}last(){return this[this.length-1]}average(){return this.sum()/this.length}range(){let t=this.map((t=>t)).sort();return{min:t[0],max:t[this.length-1]}}static get[Symbol.species](){return Array}}$ocms.ocmsmenu=[{lbl:"",id:"m_home",ico:"glyphicon glyphicon-home",fnc:"init:home"},{fnc:"separator"}],function(t){t.multline=function(t){let e=t.split("\n"),n=$$.d();return $.each(e,((t,e)=>{n.append($$.s(e))})),n.html()},t.tooltip_hidden=function(t,e){$(this).remove(),e.rendered=!1},t.isJSONDateString=function(t){return"string"==typeof t&&/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.test(t)},t.failure=function(e){11110===(e.internalCode||-1)?t.login.dlg():alert($t.f1+"\n"+(e.internalText||""))},t.getScript=function(e,n){var i=[],o=[],r=function(t){return"string"==typeof t&&""!==(t||"")},s=function(t,e){!0===bool(e.condition,!0)&&(""!==(e.script||"")&&o.push({url:e.script,module:e.module||""}),!0===r(e.css||"")?i.push(e.css):!0===Array.isArray(e.css)&&Array.prototype.push.apply(i,e.css.filter(r)))};!0===r(e||"")?o.push(e):!0===Array.isArray(e)?$.each(e,s):"object"==typeof e&&""!==(e.script||"")&&s(0,e);let a=[];$.each(i,(function(t,e){""!==(e||"")&&a.push(loadCSS(e))}));let c=o.map((function(e,n){let i=e.url,r=e.module||"";if(""===r){let t=new Promise((function(t,e){try{!async function(){$.ajax({url:i,dataType:"script",success:function(){t(o)},error:function(){e(o)},timeout:3e4})}()}catch(t){console.debug(t.message+"%o",t)}}));return t}return t.loadmodule(r,i,e.alias)}));Promise.all(c).then(n)},t.loadmodule=function(e,n,i){let o=new Promise((function(o,r){!async function(){try{let s=(n.startsWith("/")||n.startsWith(".")?"":"/")+n;import(s).then((n=>{t[e]=n[i||"default"],o(e)})).catch((t=>{console.debug(t.message+"%o",t),r(e)}))}catch(t){console.debug(t.message+"%o",t)}}()}));return o},t.ocms_auth=function(e,n,i,o){!1===$.isPlainObject(t.auth.modules)&&(t.auth.modules={});var r=0;t.auth.modules[e+(i||"")]?((r=t.auth.modules[e+(i||"")])<2&&(i||"")===auth.guid&&(r=2),r>=(n||0)&&o(false)):t.postXT({url:t.url("auth"),data:{module:e,person_guid:i||""},success:function(s){r=s[e],t.auth.modules[e+(i||"")]=r,r<2&&(i||"")===t.auth.person_guid&&(r=2),r>=(n||0)&&o(false)},error:function(e){t.failure.call(this,e)}})},t.auth.locale="de",t.ocms_prepauth=function(e,n,i){t.postXT({url:t.url("auth"),data:{fn:"csv",modules:e,person_guid:n||""},success:function(e){t.ocms_regauth(e)},error:function(e){t.failure.call(this,e)},complete:function(){i()}})},t.ocms_regauth=function(t){$.each(t||{},(function(t,e){auth.modules[t]=parseInt(e)}))},t.init=function(e){var n="string"==typeof e?e:(e.data||{}).fn||"";""!==n&&("home"===n?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),t.ov.call($("#contentframe"))):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),t.postXT({url:t.url(n+"/auth"),success:function(e){void 0===t[n]&&(t[n]={}),t[n].auth=e,e.manage>0&&t.getScript({module:n,script:["web/imdl",n,t.auth.locale||"de","js"].join("."),css:["web/imdl",n,"css"].join("."),condition:"function"!=typeof t[n].init2},(function(){t[n].init2()}))},error:function(){$("#contentframe").empty()}})))},t.menuarray=function(t){this.array=[],this.sep=function(){this.length>0&&"separator"!==this.array[array.length-1].fnc&&this.push({fnc:"separator"})},this.push=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.push.apply(this.array,t):"object"==typeof t&&this.array.push(t),t)},this.unshift=function(t){return void 0===t?null:(!0===Array.isArray(t)?Array.prototype.unshift.apply(this.array,t):"object"==typeof t&&this.array.unshift(t),t)},this.push(t)},t.menu=function(e,n){e=e||[];var i=$(this).removeClass("vis");if(!0===bool(n,!0)&&!1===i.is("#mainmenu")&&i.empty(),!1===bool(n,!1)&&i.is("#sidebar,#topbar")&&(e.unshift({id:"sbctrl",glyph:"glyphicon-th-list",aclass:"fbtn",fnc:function(){$lf()}}),$lf(0)),0===(e||[]).length)i.empty().addClass("hd");else{i.removeClass("hd");var o=!0===i.is("nav")?i:i.children("nav");1!==o.length&&(o=$("").tC("nv",i.is("#sidebar")).tC("ctxt",i.is("#topbar")).appendTo(i));var r,s=$$.ul().appendTo(o),a=function(t,e){var n=$(this).addClass("dropdown submenu");t.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"}),""!==(e.ico||"")&&t.prepend($$.sc("ico "+e.ico));var i=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(n);$.each(e.itm||[],(function(t,e){r.call(i,e)}))},c=function(t){$(this).tC("disabled","boolean"==typeof t.disabled?t.disabled:"string"==typeof t.disabled&&"subs"===t.disabled&&0===(t.itm||[]).length)};r=function(e){var n,i=$$.li({id:e.id}).attr(e.attr||{}).addClass(e.lclass).appendTo($(this)),o="string"==typeof e.fnc&&""!==e.fnc?e.fnc.split(":")[0]:"";""!==o&&"init"!==o?i.attr("role",o).appendIf($$.s(e.lbl),""!==ne(e.lbl)):(n=$$.a({class:"on",role:"button"}).addClass(e.aclass).appendTo(i).append($$.s(e.lbl)),c.call(n,e),(e.itm||[]).length>0&&a.call(i,n,e),n.click($nuf),"function"==typeof e.fnc?n.click(e.data||{},e.fnc):"init"===o&&n.click($.extend({},e.data||{},{fn:e.fnc.split(":")[1]}),t.init))},$.each(e,(function(e,n){var i,o=$$.li({id:n.id}).attr(n.attr||{}).addClass(n.lclass),a="string"==typeof n.fnc&&""!==n.fnc?n.fnc.split(":")[0]:"";if(""!==a&&"init"!==a)o.attr("role",a).appendIf($$.s(n.lbl),""!==ne(n.lbl));else{if(i=$$.a({class:"on",role:"button"}).addClass(n.aclass).appendTo(o),c.call(i,n),""!==(n.lbl||"")&&i.append($$.s(n.lbl)),""!==(n.ico||"")&&i.prepend($$.sc("ico "+n.ico)),""!==(n.glyph||"")&&i.prepend($$.sc("glyphicon "+n.glyph)),(n.itm||[]).length>0){o.addClass("dropdown"),i.append($$.sc("caret dd")).addClass("dds dropdown-toggle").attr({"aria-expanded":"false"});var l=$$.ul({class:"dropdown-menu",role:"menu"}).appendTo(o);$.each(n.itm||[],(function(t,e){r.call(l,e)}))}(n.sel||[]).length>0||(i.click($nuf),"function"==typeof n.fnc?i.click(n.data||{},n.fnc):"init"===a&&i.click($.extend({},n.data||{},{fn:n.fnc.split(":")[1]}),t.init))}o.appendTo(s)})),o.activatemenu()}},t.easytbl=(t,e)=>{e=e||{};let n=$$.tbl().addClass(e.class).css("border-collapse","collapse"),i=($$.tbody(n),!0===bool(e.frame,!1)?{padding:"5px",border:"1px solid #727272"}:{});if(!0===Array.isArray(e.header)){let t=$$.thead(n);$.each(e.header,((n,o)=>$$.th(t).css(e.cellcss||i).rwText(o)))}else if(!0===bool(e.header,!1)&&(t||[]).length>0){let o=$$.thead(n);$.each(Object.keys(t[0]),((t,n)=>$$.th(o).css(e.cellcss||i).rwText(n)))}return $.each(t||[],((t,o)=>{let r=$$.tr();$.each(o,((t,n)=>{n=n||"";let o=$$.td(r).css(e.cellcss||i);n instanceof jQuery?o.append(n):"string"==typeof n&&("<"===n.substring(0,1)?o.append(n):o.text(n))})),n.append(r)})),n},t.dlgtbl=(e,n,i)=>{i=i||{};let o=t.easytbl(e,i);t.dlg(o,$.extend({title:n},i))},t.dlg=function(t,n){n=n||{};let i=$("body > .modal").length>0,o=t=>typeof n[t],r=t=>"function"===o(t);if(!0===bool(n.exclusive,!0)&&!0===i)return void alert($t.dbldlg||"Es ist bereits ein Dialog geöffnet");let s=$$.dc("modal",$("body")),a=$$.dc("modal-dialog",s);!1===isNaN(n.zindex)?s.css("zIndex",n.zindex):!0===i&&s.css("zIndex",parseInt($("body > .modal:last").cssValue("zIndex"))+200),!1===isNaN(n.zindex_min)&&s.cssValue("zIndex")').appendTo(d)),""!==ne(n.title)&&(c=$$.dc("modal-header",d),$("

        ").text(n.title).appendTo(c));let p=$$.dc("modal-body",d),f=$$.dc("modal-footer",d);t instanceof jQuery==!0&&p.append(t);let m=function(t){t&&"function"==typeof t.stopPropagation&&t.stopPropagation(),a.removeClass("in"),!0===r("closing")&&n.closing.call(d),p.hide().emptyWithEditors(),s.remove(),!0===r("close")&&n.close.call(d)};if(d.find(":input[required]").length>0&&($$.dc("note_required",f).append($$.sc("ind_required","*")).append($$.s($t.t1||"Eingabe erforderlich")),$$.dc("note_invalid",f).append($$.s($t.t2||"Bitte überprüfen Sie Ihre Eingaben im Formular."))),!0===r("cancel")){$$.bbtn(n.cancelbutton||"Abbrechen","cancel").attr({type:"button",role:"cancel"}).appendTo(f).click((function(t){n.cancel.call(d,t);t.stopPropagation(),m()}))}if(!0===r("confirm")){let t=$$.bbtn(n.button||"OK","confirm").attr({type:!0===bool(n.form,!1)?"submit":"button",role:"confirm"}).appendTo(f);!0===h?(d.submit((function(t){try{n.confirm.call(d,t)}finally{t.preventDefault()}return!1})),d.on("modal_submit",(function(){n.confirm.call(d,e)}))):(t.click((function(t){n.confirm.call(d,t);t.stopPropagation()})),d.on("modal_submit",(function(){t.click()})))}else!0===h&&d.submit((function(t){return t.preventDefault(),!1}));return d.on("modal_close",(function(){m()})),l.click(m),!0===r("opening")&&n.opening.call(d),a.addClass("in"),ne(n.mode).indexOf("maxbody")>-1&&p.css("min-height",(u.height()-c.outerHeight()-f.outerHeight()).toString()+"px"),!0===r("open")&&n.open.call(d),{hd:c,bdy:p,ft:f,ct:u,dlg:a,c:d}},t.mform=function(e){let n=$$.dc("form-body"),i=Array.isArray(e)?e:e instanceof fields_definition?e.fields:[];return $.each(i||[],(function(e,i){let o=i.type||"";if("ignore"===o)return!0;let r=$$.dc("form-group",n),s=i.id||"dlg_"+(i.name||"")+("html"===i.type?"_"+(65536*(1+Math.random())||0).toString(16).substr(9):""),a=$$.lbl(i.label||i.name,{for:s}).appendTo($$.dc("form-itm",r)),c=$$.dc("form-itm",r),l=$$.i({id:s,name:i.name,placeholder:i.placeholder,type:i.type});switch(o){case"email":i.pattern=ne(i.pattern,"[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$");break;case"url":i.pattern=ne(i.pattern,"https?://.+");break;case"number":i.pattern=ne(i.pattern,"[-+]?[0-9]*[.,]?[0-9]*"),l.attr("type","text"),l.attr("inputmode","decimal"),l.attr("step",i.precision||"any"),l.attr("data-format","float");break;case"integer":case"int":i.pattern=ne(i.pattern,"[-+]?[0-9]*"),l.attr("type","number"),l.attr("data-format","integer");break;case"date":if(""!==ne(i.pattern,$t.datepattern)&&(i.pattern=ne(i.pattern,"("+$t.datepattern+")|([0-9]{4}.(0[1-9]|1[012]).(0[1-9]|1[0-9]|2[0-9]|3[01]))")),""!==ne(i.placeholder,$t.dateplaceholder)&&l.attr("placeholder",ne(i.placeholder,$t.dateplaceholder)),"string"==typeof i.value){var u=i.value.substr(0,10);i.value="date"!==l.prop("type")?fdt(u+"T00:00:00",ne(i.dateformat,$t.dateformat)):u}l.attr("data-format","date:"+ne(i.dateformat,$t.dateformat)+";yyyy-MM-dd");break;case"datetime":l.attr("type","datetime-local"),""!==ne(i.pattern,$t.datetimepattern)&&(i.pattern=ne(i.pattern,"("+$t.datetimepattern+")|([0-9]{4}-(0[1-9]|1[012])-(0[1-9]|1[0-9]|2[0-9]|3[01])\\s([0-5][0-9]):([0-5][0-9]))")),""!==ne(i.placeholder,$t.datetimeplaceholder)&&l.attr("placeholder",ne(i.placeholder,$t.datetimeplaceholder)),"string"==typeof i.value&&"T"===i.value.substr(10,1)&&(i.value="datetime"!==l.prop("type").substr(0,8)?fdt(i.value,ne(i.datetimeformat,$t.datetimeformat)):i.value),l.attr("data-format","datetime:"+ne(i.datetimeformat,$t.datetimeformat)+";yyyy-MM-dd HH:mm:ss");break;case"hidden":r.addClass("hd");break;case"html":case"text":l=$$.txt({id:s,name:i.name,placeholder:i.placeholder,type:i.type}),l.tC("tinymce","html"===i.type);break;case"bool":case"boolean":i.url=[{value:"true",label:($t||{}).true||"Yes"},{value:"false",label:($t||{}).false||"No"}],"boolean"==typeof i.value&&(i.value=i.value?"true":"false");case"select":l=$$.sel({id:s,name:i.name,type:i.type}),!1===bool(i.required,!1)&&$$.eOpt().appendTo(l);try{var d=function(t){!0===Array.isArray(t)&&$.each(t,(function(t,e){"string"==typeof e?$$.opt(e,e).appendTo(l):!0===Array.isArray(e)?$$.opt(e[0],e[1]).appendTo(l):"object"==typeof e&&$$.opt(e.value,e.label||e.text).appendTo(l)}))};!0===Array.isArray(i.url)?d(i.url):"function"==typeof i.url?i.url.call(l):"string"==typeof i.url&&t.postXT({url:i.url,success:d})}catch(t){$.noop()}break;default:""!==ne(i["max-length"])&&l.attr("max-length",i["max-length"])}""!==ne(i.pattern)&&l.attr("pattern",i.pattern),l.val(i.value).change(),l.change((function(){$(this)[0].setCustomValidity("")})),l.addClass("form-control").prop("required",bool(i.required,!1)).prop("readonly",bool(i.readonly,!1)).appendTo(c),!0===bool(i.required,!1)&&a.append($$.sc("ind_required","*")),"object"==typeof i.attr&&l.attr(i.attr),"object"==typeof i.prop&&l.prop(i.prop),"string"==typeof i.class&&l.addClass(i.class),"function"==typeof i.change&&(l.change(i.change),!0===bool(i.applychange,!1)&&void 0!==i.value&&l.change()),""!==(i.note||"")&&$$.dc("form-note",c).rwText(i.note),"function"==typeof i.complete&&i.complete.call(l)})),n},t.initMCE=function(t,e){t=$(t),e=e||{};try{let n={target:t[0],inline:!1,width:e.width||"100%",statusbar:!1,document_base_url:window.location.origin+"/",content_style:"ph:before {content: '«'; color: #BBB; font-style:italic; } ph:after {content: '»'; color: #BBB; font-style:italic; } ph { color: #AAA; font-style:italic; }",relative_urls:!1,remove_script_host:!1};!0===bool(e.hidemenu,!1)&&(n.menubar=!1,n.menu={}),!0===bool(e.hidetoolbar,!1)&&(n.toolbar=!1),$.extend(n,e||{}),tinymce.init(n)}catch(t){alert(t.message)}},t.dlgform=function(e,n){n=n||{};let i,o=$$.dc("frm").append(t.mform(e||[]).addClass("stacked"));n.addcontent instanceof jQuery&&o.append(n.addcontent),"function"==typeof n.submit?i=n.submit:"function"==typeof n.success&&(i=function(e){var i=$(this).ldng(1),o=$.extend({loginaccount:t.auth.account||""},i.serializeObject(bool(n.checkvalidity,!0),{typedvalues:bool(n.typedvalues,!1)}));""!==(n.url||"")?t.postXT({url:n.url,data:o,success:function(t){n.success.call(this,t),i.trigger("modal_close")},error:function(){alert($t.l17)},complete:function(){i.ldng(0)},timeout:6e4}):(n.success.call(this,o),i.trigger("modal_close"))});let r={form:!0,title:n.title||"",button:n.button||$t.submit,confirm:i,size:n.size||[500,600],open:function(){let e=$(this).find(".tinymce");e.length>0&&t.initMCE(e,n.tinymce||{})}};return t.dlg.call(this,o,r)},t.login.dlg=function(e){e=e||{};let n=[{name:"userinfo",label:$t.l1,type:"string",value:t.auth.login,change:t.login.uichange,required:!0},{name:"userlogin",type:"hidden",required:!0,value:t.auth.login},{name:"username",type:"string",label:$t.l4,required:!0,readonly:!0,placeholder:$t.l5,value:t.auth.fullname_rev},{name:"userpass",type:"password",label:$t.l3,required:!0,placeholder:$t.l3}];""===(t.auth.account||"")&&n.unshift({id:"dlg_loginaccount",name:"loginaccount",type:"string",required:!0,value:t.auth.account});let i=$$.dc("frm").append(t.mform(n).addClass("stacked")),o=t.dlg.call(this,i,{form:!0,title:$t.l0,button:$t.submit,confirm:function(n){var i=$(this).ldng(1),o=$.extend({loginaccount:t.auth.account||""},i.serializeObject());t.postXT({url:"/vt/login",data:o,success:function(n){""!==((n||{}).login||"")&&(i.trigger("modal_close"),t.auth=n,"object"==typeof e.ajo&&(e.ajo.islogin,$.ajax(e.ajo)))},error:function(){alert($t.l17)},complete:function(){i.ldng(0)},timeout:6e4})},size:[500,600]}),r=$$.dc("modal-content").css("height","auto").attr("novalidate","true").append($$.dc("modal-header").appendIf($("

        ").text(t.auth.accountname),""!==(t.auth.accountname||"")).append($("

        Vereinsmanager

        ")));o.dlg.prepend(r)},t.addNoEntryInfo=function(t){$(this).append($$.dc("noentryinfo").text(t||$t.t11))}}($ocms),Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),Element.prototype.closest||(Element.prototype.closest=function(t){var e=this;do{if(Element.prototype.matches.call(e,t))return e;e=e.parentElement||e.parentNode}while(null!==e&&1===e.nodeType);return null}),function(t,e){var n,i;"object"==typeof window&&(window[t]=(n=function(t){var e=window,n=document.body,i=document.documentElement,o=Math.max(0,e.pageXOffset||i.scrollLeft||n.scrollLeft||0)-(i.clientLeft||0),r=Math.max(0,e.pageYOffset||i.scrollTop||n.scrollTop||0)-(i.clientTop||0);return{x:t?Math.max(0,t.pageX||t.clientX||0)-o:0,y:t?Math.max(0,t.pageY||t.clientY||0)-r:0}},(i=function(t,e){t&&t instanceof Element&&(this._container=t,this._options=e||{},this._clickItem=null,this._dragItem=null,this._showDragItem="boolean"!=typeof this._options.dragItem||!1!==this._options.dragItem,this._hovItem=null,this._sortLists=[],this._click={},this._dragging=!1,this._dragHandleClass=this._options.dragHandleClass||"",this._parentident=this._options.parentident||"",this._swapdone="function"==typeof this._options.swapdone?this._options.swapdone:null,this._onend="function"==typeof this._options.onend?this._options.onend:null,this._container.setAttribute("data-is-sortable",1),this._container.classList.add("sortable"),this._container.style.position="static",window.addEventListener("mousedown",this._onPress.bind(this),!0),window.addEventListener("touchstart",this._onPress.bind(this),!0),window.addEventListener("mouseup",this._onRelease.bind(this),!0),window.addEventListener("touchend",this._onRelease.bind(this),!0),window.addEventListener("mousemove",this._onMove.bind(this),!0),window.addEventListener("touchmove",this._onMove.bind(this),!0))}).prototype={constructor:i,toArray:function(t){t=t||"id";for(var e=[],n="",i=0;ii.left&&ei.top&&n0?a.mousedown(c).addClass("dctrl"):s.mousedown(c).addClass("dctrl"),t(this)}}(jQuery),$(document).ready((function(){$("html").click((function(t){$nuf()})),$("#listframe").click((function(t){t.stopPropagation(),$nuf()})),$("#mainmenu").ocmsmenu($ocms.ocmsmenu),$("#mainmenu").activatemenu()})),$.extend($t,{m_inv:"Rechnungen",m_req:"Aufträge",m_rep:"Berichte",m_todo:"ToDos",m_bcd:"BankBuchungen",m_admin:"Administration",m_admin_status:"Systemüberwachung",m_usr:"Benutzerverwaltung",m_usrgrp:"Gruppenverwaltung",m_specialties:"Spezialfunktionen",rsp:"Passwort ändern",pnm:"Die Passwörter stimmen nicht überein",cps:"Das neue Passwort wurde gespeichert.",pwr:"Bitte wählen Sie ein starkes Passwort (min 8 Zeichen, davon jeweils min 2 Zahlen, kleine und große Buchstaben, Sonderzeichen sind optional).",smsc:"Sie beötigen für diese Funktion einen SMS-Code.\nSoll dieser nun versandt werden?",wdc:"Doppelt klicken, um die Box zu aktualisieren.",wldg:"Widgets werden geladen…",wdg:{}}),$t.rspf={sms:"Der SMS-Code konnte nicht bestätigt werden",valid:"Das alte Passwort ist nicht korrekt",requirements:"Das Passwort entspricht nicht den Anforderungen.\n"+$t.pwr},$fd={rsp:new fields_definition("","",[{name:"opw",label:"aktuelles Passwort",type:"password",required:!0,attr:{"auto-complete":"current-password"}},{name:"npw",label:"neues Passwort",type:"password",required:!0,pattern:"(.{6,})",attr:{"auto-complete":"new-password"}},{name:"npwc",label:"neues Passwort (Bestätigung)",type:"password",required:!0,attr:{"auto-complete":"new-password"},note:$t.pwr},{name:"code",label:"SMS-Code",type:"string",required:!0,attr:{"auto-complete":"one-time-code"}}])},$ocms.init=function(t){var e="string"==typeof t?t:(t.data||{}).fn||"";""!==e&&("home"===e?($cfr(),$lfr(),$("#topbar").ocmsmenu([],!0),$("#activemodule").text($t.ov),$fis.ov()):($cfr(),$lfr(),$("#topbar").ocmsmenu([]),$ocms.postXT({url:$ocms.url(e+"/auth"),success:function(t){if(void 0===$ocms[e]&&($ocms[e]={}),$ocms[e].auth=t,t.manage>0){let t="?v="+encodeURIComponent($ocms.auth.assetversion||"0");$ocms.getScript({module:e,script:["/web/fis",e,$ocms.auth.locale||"de","js"].join(".")+t,css:["/web/fis",e,"css"].join(".")+t,condition:"function"!=typeof $ocms[e].init2},(function(){$ocms[e].init2()}))}},error:function(){$("#contentframe").empty()}})))};var $fis={auth:{},db:function(){$("#mainmenu_activemodule").text($t.ov);let t=$(this).empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){$.each(t,(function(t,n){var i=$$.dc("wdg_frame",e,{"data-wdg":n}).hide();$ocms.wdg.call(i,{wdg:n})}))},loading:e})},ValidateEmail:function(t){return!!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test(t)},cf:t=>{let e=$("#contentframe");return!0===bool(t,!1)&&e.empty().rC("hd"),e},lf:t=>{let e=$("#listframe");return!0===bool(t,!1)&&e.empty().aC("hd").rC("fix"),e},frm_edit:function(t){let e=$fis.cf(!1),n=e.children(".cfrm"),i=e.children(".edit_frm");return n.length<1?n=$$.dc("cfrm hd").prependTo(e):!0===bool(t,!1)&&n.empty(),i.length<1&&(i=$$.dc("edit_frm").insertAfter(n)),i.empty()},frm_list:function(t,e){let n=$fis.cf(!1),i=n.children(".cfrm"),o=n.children(".list_frm");return i.length<1?i=$$.dc("cfrm hd").prependTo(n):!0===bool(t,!1)&&i.empty(),!0===bool(e,!1)&&n.children(".edit_frm").remove(),o.length<1&&(o=$$.dc("list_frm").appendTo(n)),o.empty()},lfm:()=>{let t=$fis.lf(!1),e=t.children(".lfrm");return e.length<1&&(e=$$.dc("lfrm").prependTo(t)),e},getAuth:(t,e)=>new Promise(((n,i)=>{$fis.auth[t]&&!1===bool(e,!1)?n($fis.auth[t]||-1):$ocms.postXT({url:$ocms.url("auth"),data:{module:t},success:e=>{$fis.auth[t]=e.auth||-1,n($fis.auth[t]||-1)},error:()=>{i()}})})),prepAuth:t=>new Promise(((e,n)=>{$ocms.postXT({url:$ocms.url("auth"),data:{module:t,array:1},success:t=>{$.extend($fis.auth,t||{})},complete:()=>{e()}})})),isAuth:(t,e)=>($fis.auth[t]||-1)>=(e||1),resetPass:function(t,e){confirm($t.smsc)&&($ocms.postXT({url:$ocms.url("account/sms"),data:{fn:"pwc"}}),$ocms.dlgform($fd.rsp.clone(),{title:$t.rsp||"",submit:function(t){var e=$(this).ldng(1),n=$.extend({loginaccount:$ocms.auth.account||""},e.serializeObject(!0,{typedvalues:!0}));(n.npw||"")!==(n.npwc||"")?e.find('input[name="npw"]:first')[0].setCustomValidity($t.pnm):$ocms.postXT({url:$ocms.url("account/changepassword"),data:n,success:function(t){alert($t.cps),e.trigger("modal_close")},error:function(t){alert($t.rspf[t.getResponseHeader("x-ocms-std")])},complete:function(){e.ldng(0)},timeout:6e4})}}))},wdg:function(t){let e=$(this).empty();$ocms.postXT({url:$ocms.url("wdg/one"),data:{short_name:t.wdg},timeout:9e4,success:function(n,i,o){let r=t.wdg,s=n[r];if(!s)return void e.ldng(0);let a=$.inArrayRegEx("dblwidth",s.rendering_options)>-1,c=$.inArrayRegEx("tiny",s.rendering_options)>-1;e.toggleClass("dbl",a&&!c).toggleClass("tny",c);$$.dc("wdg_hd",e,{title:ne(s.description,$t.wdc)}).toggleClass("dbl",a).text(ne(s.name,t.wdg)).dblclick((function(t){t.stopPropagation(),$fis.wdg.call(e,{wdg:r})}));let l=$$.dc("wdg_cnt",e).toggleClass("dbl",a).hide(),u=$.inArrayRegEx("bgcolor",s.rendering_options);switch(u>-1&&l.css("backgroundColor",s.rendering_options[u].toString().right(":")),s.type){case"table":var d=$$.tblset({},l),h=$$.tr().appendTo(d.hd),p=$t.wdg[r.indexOf("wdg_ev_")>=0?"wdg_ev_":r]||{};$.each(s.columns,(function(t,e){var n=p[e]?p[e].label:e;$$.th().text(n).appendTo(h)})),$.each(s.data,(function(t,e){var n=$$.tr().appendTo(d.bdy);$.each(s.columns,(function(t,i){var o=$$.td().appendTo(n);e[i]instanceof Date||!0===$ocms.isJSONDateString(e[i])?o.text(fdt(e[i],$t.dateformat)):o.rwText(e[i])}))})),$.inArray("firstrow_bold",s.rendering_options)>-1&&h.nextAll("tr:first").css("font-weight","bold");break;case"ind":$$.dc("ind",l).addClass("sts_"+(s.data.status||"")).append([$$.dc("ind").text(s.data.value),$$.lbl(s.data.label)]);break;case"image_url":l.css("background","url('"+s.url+"') no-repeat center center transparent");break;case"image_base64":l.css("background","url('data:image/png;base64,"+s.image+"') no-repeat center center transparent");break;case"html":if(l.html(s.html),$.inArray("reload_10min",s.rendering_options)>-1){var f=l.find("iframe");setTimeout((function(){f.attr("src",(function(t,e){return e}))}),6e5)}}$.inArray("reload_30min",s.rendering_options)>-1&&"html"!==s.type&&setTimeout((function(){$fis.wdg.call(e,{wdg:r})}),18e5),e.is(":visible")?l.slideDown(150):(l.show(),e.fadeIn(150))},error:function(t){e.is(":visible")&&e.slideUp(150),$fis.failure.call(this,t)},complete:function(){e.ldng(0).trigger("wdg:settled")}})},ov:function(){$fis.lf(!0);let t=$("#contentframe").empty(),e=$$.d({id:"dashboard_frame"}).appendTo(t);$ocms.postXT({url:$ocms.url("wdg/my"),success:function(t){let n=$$.dc("wdg_loading_ind",e).text($t.wldg||"").hide(),i=t.length,o=function(){--i<=0&&n.fadeOut(150)};i>0&&n.show(),$.each(t,(function(t,n){var i=$$.dc("wdg_frame",e,{"data-wdg":n}).hide().one("wdg:settled",o);$fis.wdg.call(i,{wdg:n})}))},loading:e})}};$fis.notifications={connection:null,init:function(){"undefined"!=typeof signalR&&null===this.connection&&$ocms.auth.useraccount_id&&(this.ensureFrame(),this.connection=(new signalR.HubConnectionBuilder).withUrl("/notifications").withAutomaticReconnect().build(),this.connection.on("notification",(t=>{this.push(t)})),this.connection.onclose((()=>{console.warn("Notification connection closed; retrying in 5s."),this.connection=null,setTimeout((()=>this.init()),5e3)})),this.start())},start:function(){this.connection.start().catch((t=>{console.warn("Notification connection failed to start; retrying in 5s.",t),this.connection=null,setTimeout((()=>this.init()),5e3)}))},ensureFrame:function(){$("#notification_frame").length<1&&$("
        ",{id:"notification_frame"}).appendTo($("footer:first").length?"footer:first":"body")},push:function(t){this.ensureFrame(),t=t||{};let e=$("
        ",{class:"notification_item"}).addClass((t.severity||"info").toLowerCase()).append($("