feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s

- 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.
This commit is contained in:
Stefan
2026-09-17 21:17:23 +02:00
parent 86f6a60860
commit 249a945f5e
59 changed files with 2720 additions and 457 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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).
+143
View File
@@ -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<AzureBlobStorageService>.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<CancellationToken>()))
.ReturnsAsync(Response.FromValue(false, Mock.Of<Response>()));
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
Assert.Null(result);
blob.Verify(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()),
Times.Never);
blob.Verify(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
}
/// <summary>
/// Source blob exists; the backup blob accepts a server-side copy whose operation completes
/// immediately. Returns the mocks needed for verification.
/// </summary>
private static (Mock<BlobServiceClient> service, Mock<BlobClient> sourceBlob, Mock<BlobClient> backupBlob, Mock<CopyFromUriOperation> 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<CancellationToken>()))
.ReturnsAsync(Response.FromValue(true, Mock.Of<Response>()));
var copy = new Mock<CopyFromUriOperation>();
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(Response.FromValue(3L, Mock.Of<Response>()));
var backupBlob = new Mock<BlobClient>();
backupBlob.Setup(b => b.Uri).Returns(backupUri);
backupBlob.Setup(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(copy.Object);
container.Setup(c => c.GetBlobClient(It.Is<string>(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<long>((_, value, _, _) => Interlocked.Add(ref succeeded, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.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<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()), Times.Once);
copy.Verify(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()), Times.Once);
sourceBlob.Verify(b => b.DownloadContentAsync(It.IsAny<CancellationToken>()), Times.Never);
backupBlob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
// The original stays: it remains the current archive until the next regeneration overwrites it.
sourceBlob.Verify(b => b.DeleteIfExistsAsync(
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), 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<CancellationToken>()))
.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<long>((_, value, _, _) => Interlocked.Add(ref failed, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.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<CancellationToken>()))
.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<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.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.");
}
}
+132
View File
@@ -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;
/// <summary>
/// Covers the Fuchs override of <see cref="PWMailerClientService.ValidateMessage"/>: 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.
/// </summary>
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<HttpResponseMessage> 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<PWMailerClientService>.Instance), handler);
}
private static PWMailerMessage Message(params PWMailerAttachment[] attachments)
{
var m = new PWMailerMessage { To = "a@example.com", Subject = "Hi", Body = "<p>x</p>", Reference = "R1" };
m.Attachments.AddRange(attachments);
return m;
}
public static IEnumerable<object[]> 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<PWMailerAttachment>(), new MailerAttachmentLimits()));
}
}
@@ -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;
/// <summary>
/// Covers <see cref="InvoiceFileBackupService.BackupAndClearAsync"/>: 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.
/// </summary>
public class InvoiceFileBackupServiceTests
{
private sealed class RecordingLogger : ILogger<InvoiceFileBackupService>
{
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
public readonly List<Entry> Entries = new();
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> 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<string, object?>
{
["Id"] = id,
["InvoiceId"] = "R2026-0258",
["DocumentName"] = $"Rechnung_{id}.pdf",
["IsFinal"] = isFinal,
["IsExternal"] = isExternal,
["hasFile"] = hasFile,
}),
IsDraft = !isFinal,
};
private static (InvoiceFileBackupService svc, RecordingLogger log, Mock<IInvoiceService> invoices, Mock<IBlobStorageService> blob)
MakeService(FdsInvoiceData? invoice, string? connectionString = null)
{
var log = new RecordingLogger();
var invoices = new Mock<IInvoiceService>();
invoices.Setup(i => i.LoadInvoiceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DatabaseSecurity>()))
.ReturnsAsync(invoice ?? new FdsInvoiceData());
var blob = new Mock<IBlobStorageService>();
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["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<object[]> NotFoundShapes() =>
[
[ new FdsInvoiceData() ],
[ new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>()) } ],
];
[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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
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<CancellationToken>()))
.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<CancellationToken>()), 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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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"));
}
}
+306 -257
View File
@@ -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;
/// <summary>
/// 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
/// <see cref="IPWMailerClientService"/> (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 <see cref="PWMailerMessage"/> 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.
/// </summary>
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<PWMailerPushReceipt> 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<PWMailerPushReceipt> Failed(int status, string error) =>
new() { StatusCode = status, Error = error, Attempts = 1, Operation = "push_com" };
/// <summary>The client refused the call before sending (validation, options, token): status 0, no attempt.</summary>
private static PWMailerResult<PWMailerPushReceipt> Refused(string error) =>
new() { StatusCode = 0, Error = error, Attempts = 0, Operation = "push_com", Exception = new ArgumentException(error) };
private sealed class MailerStub
{
public readonly Mock<IPWMailerClientService> Mock = new(MockBehavior.Loose);
public readonly List<PWMailerMessage> Pushed = new();
public int CallCount => Pushed.Count;
public MailerStub(PWMailerResult<PWMailerPushReceipt>? push = null)
{
_status = status;
_body = body;
var result = push ?? Accepted();
Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
.Callback<PWMailerMessage, CancellationToken>((m, _) => Pushed.Add(m))
.ReturnsAsync(result);
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
/// <summary>Answers like a Mailer that processed every file it received.</summary>
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<PWMailerMessage>(), It.IsAny<CancellationToken>()))
.Callback<PWMailerMessage, CancellationToken>((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<HttpResponseMessage> 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);
}
/// <summary>
/// Captures what would be written to <c>fds__logEmail</c> 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<string> log)> Entries = new();
public readonly List<string> Configs = new();
public readonly List<string> Guids = new();
public CapturingComService(
IOptions<ProcessWebComSettings> settings,
IOptions<FuchsEmailSettings> emailSettings,
IHttpClientFactory httpClientFactory)
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
{
}
public CapturingComService(IOptions<ProcessWebComSettings> settings, IOptions<FuchsEmailSettings> emailSettings,
IPWMailerClientService mailer)
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, mailer) { }
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
DateTime sent, bool success, IEnumerable<string> 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<ProcessWebComSettings> 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<ProcessWebComService>.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<ProcessWebComService>.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", "<p>hi</p>", "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", "<p>hi</p>", "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", "<p>hi</p>", 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", "<p>hi</p>", "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", "<p>x</p>", "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", "<p>x</p>", "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<PWMailerMessage>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("simulated client failure"));
var svc = CreateCapturing(mailer);
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "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", "<p>x</p>", 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", "<p>x</p>", "kunde@example.de", "Kunde");
@@ -317,18 +305,19 @@ public class ProcessWebComServiceTests
public static IEnumerable<object[]> 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<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", 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", "<p>x</p>", "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", "<p>hi</p>", "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", "<p>hi</p>", "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", "<p>hi</p>", "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", "<p>hi</p>", "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", "<p>hi</p>", "kunde@example.de", "Kunde",
cc: "zusatz@example.de");
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "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", "<p>hi</p>", "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", "<p>hi</p>", "realcustomer@example.de", "Kunde",
cc: "zusatz@example.de");
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "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<object[]> 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", "<p>hi</p>", 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<string, byte[]> { ["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<string, byte[]> { ["Rechnung R2026-0119.pdf"] = pdf, ["zugferd.xml"] = xml };
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments);
bool result = await svc.SendEmailAsync("inv_map", "Subject", "<p>hi</p>", "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("<p>hi</p>", 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<string, byte[]> { ["Rechnung.pdf"] = [1, 2, 3] };
bool result = await svc.SendEmailAsync("inv_mismatch", "S", "<p>x</p>", "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<long>((_, _, 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", "<p>x</p>", "kunde@example.de", "Kunde",
new Dictionary<string, byte[]> { ["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", "<p>x</p>", "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", "<p>x</p>", "kunde@example.de", "Kunde",
new Dictionary<string, byte[]> { ["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<long>((_, _, tags, _) =>
{
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
});
listener.Start();
var svc = CreateCapturing(new MailerStub(new PWMailerResult<PWMailerPushReceipt>
{
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", "<p>x</p>", "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<PWMailerVerifyResult> 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<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
}
}
private static ProcessWebComService VerifyService(Mock<IPWMailerClientService> mailer, string accountId = "acct", string token = "tok", string serverId = "") =>
new(NullLogger<ProcessWebComService>.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<ProcessWebComService>.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<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).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<CancellationToken>()), 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<ProcessWebComService>.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<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync("srv-1", It.IsAny<CancellationToken>())).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<ProcessWebComService>.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<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync("srv-wrong", It.IsAny<CancellationToken>())).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<ProcessWebComService>.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<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).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<ProcessWebComService>.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<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { 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<ProcessWebComService>.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<IPWMailerClientService>();
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { 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<ProcessWebComService>.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<IPWMailerClientService>();
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<string?>(), It.IsAny<CancellationToken>()), Times.Never);
}
// ── Metric emission (performance indicator) ────────────────────────────────
@@ -711,11 +762,9 @@ public class ProcessWebComServiceTests
listener.SetMeasurementEventCallback<long>((_, 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", "<p>x</p>", "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";
}
+148
View File
@@ -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<string, string?>? overrides = null)
{
@@ -51,6 +53,7 @@ public class SystemStatusServiceTests
Mock<IBlobStorageService>? blob = null,
Mock<IMfrClientFactory>? mfr = null,
Mock<IComService>? com = null,
Mock<IPdfService>? 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<IPdfService>();
if (pdf is null)
{
pdfMock.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
pdfMock.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
.Returns(FakePdfBytes);
}
return new SystemStatusService(
config ?? Config(),
env.Object,
@@ -74,6 +87,7 @@ public class SystemStatusServiceTests
(blob ?? new Mock<IBlobStorageService>()).Object,
(mfr ?? new Mock<IMfrClientFactory>()).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<string, byte[]>? capturedAttachments = null;
FuchsPdf.FdsTextBlocks? capturedBlocks = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), false))
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => capturedBlocks = tb)
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>())).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<MigraDoc.DocumentObjectModel.Document>()), Times.Once);
}
[Fact]
public async Task SendTestEmailAsync_AttachPdfFalse_SendsWithoutAttachmentAndSkipsRendering()
{
Dictionary<string, byte[]>? capturedAttachments = new();
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>(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<IComService>();
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.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<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()), Times.Never);
}
[Fact]
public async Task SendTestEmailAsync_MailerRejectsWithAttachment_ReportsAttachmentNotConfirmed()
{
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.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<string, byte[]>? capturedAttachments = null;
var com = new Mock<IComService>();
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
(_, _, _, _, _, att, _) => capturedAttachments = att)
.ReturnsAsync(true);
var pdf = new Mock<IPdfService>();
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
.Returns<FuchsPdf.FdsTextBlocks, bool>((tb, draft) => FuchsPdf.WriteLetter(tb, draft, FuchsPdf.DeCulture));
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
.Returns<MigraDoc.DocumentObjectModel.Document>(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<IPdfService>(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()
{
+24 -3
View File
@@ -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();
@@ -155,8 +155,11 @@ public partial class IntranetController
{
var remdoc = new Dictionary<string, byte[]>
{ [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<string, byte[]>
{ [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"))}",
@@ -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/<id> 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<IActionResult> 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();
}
}
+4
View File
@@ -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),
+2 -2
View File
@@ -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`
+73
View File
@@ -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:<fn>` 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 + <form> + 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/<internal Id>` (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)
+14 -5
View File
@@ -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/<component> → { 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.
@@ -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<FuchsMailerClient>()` (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<PWMailerPushReceipt>`:
| 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_<Id>` 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_<Id>` (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<FuchsMailerClient>()`.
- `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)
@@ -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/<invoiceId>`, 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_<timestamp>_…` 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.
@@ -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.
@@ -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_<Id>` 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.
+13 -5
View File
@@ -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_<Id>", 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
+9
View File
@@ -51,6 +51,15 @@ public static class FuchsTelemetry
Meter.CreateCounter<long>("fuchs.blobstorage.uploads", "{upload}", "Number of documents successfully archived to Azure Blob Storage.");
public static readonly Counter<long> BlobUploadsFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.uploads.failed", "{upload}", "Number of documents that failed to archive to Azure Blob Storage.");
public static readonly Counter<long> BlobBackupCopiesSucceeded =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies", "{copy}",
"Number of archived blobs successfully copied to a backup name (admin invoice-file backup/clear).");
public static readonly Counter<long> BlobBackupCopiesFailed =
Meter.CreateCounter<long>("fuchs.blobstorage.backupcopies.failed", "{copy}",
"Number of blob backup-copy attempts that failed.");
public static readonly Counter<long> InvoiceFileBackups =
Meter.CreateCounter<long>("fuchs.invoice.filebackups", "{backup}",
"Admin-triggered invoice file backup+clear actions (Do_Process_Admin backupclearinvoicefile), tagged by outcome.");
public static readonly Counter<long> SystemProbes =
Meter.CreateCounter<long>("fuchs.systemstatus.probes", "{probe}",
"Admin system-status connectivity probes executed, tagged by component and outcome.");
+18 -2
View File
@@ -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<ProcessWebComSettings>(builder.Configuration.GetSection("Fuchs:Mailer"));
builder.Services.AddOptions<PWMailerClientOptions>()
.Configure<IOptions<ProcessWebComSettings>>((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<FuchsMailerClient>();
// 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<FuchsEmailSettings>(builder.Configuration.GetSection("Fuchs:Email"));
@@ -148,7 +164,6 @@ public class Program
builder.Services.AddSingleton<IERechnungValidator, ProcessWebERechnungValidator>(); // 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<IWidgetService, FuchsWidgetService>();
builder.Services.AddScoped<IReportService, FuchsReportService>();
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<IInvoiceFileBackupService, InvoiceFileBackupService>();
builder.Services.AddScoped<IReminderService, ReminderService>();
builder.Services.AddScoped<IEventService, EventService>();
+54
View File
@@ -68,6 +68,60 @@ public class AzureBlobStorageService : IBlobStorageService
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
public Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName,
CancellationToken cancellationToken = default)
=> CopyToBackupAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, cancellationToken);
private async Task<Uri?> 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<bool> 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<string, object?>("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<string, object?>("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<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default)
{
+34
View File
@@ -0,0 +1,34 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OCORE.web;
using OCORE.web.pwmailer;
namespace Fuchs.Services;
/// <summary>
/// The OCORE_web <see cref="PWMailerClientService"/> with Fuchs' pre-validation: on top of the
/// contract rules the base applies, every outbound message is checked against the Mailer's
/// attachment limits (<see cref="MailerAttachmentPolicy"/>, configured under
/// <c>Fuchs:Mailer:Attachments</c>) before any bytes are sent. Registered as the application's
/// <see cref="IPWMailerClientService"/> in <c>Program.cs</c>; options are mapped from
/// <c>Fuchs:Mailer</c> (<see cref="ProcessWebComSettings"/>) there.
/// </summary>
public class FuchsMailerClient : PWMailerClientService
{
private readonly MailerAttachmentLimits _limits;
public FuchsMailerClient(IOptions<PWMailerClientOptions> options, IOptions<ProcessWebComSettings> settings,
IHttpClientFactory httpClientFactory, ILogger<PWMailerClientService> logger,
IOCORE_web_securityservice? securityService = null)
: base(options, httpClientFactory, logger, securityService)
{
_limits = settings.Value.Attachments;
}
protected override IReadOnlyList<string> ValidateMessage(PWMailerMessage message)
{
var problems = new List<string>(base.ValidateMessage(message));
problems.AddRange(MailerAttachmentPolicy.Validate(message.Attachments, _limits));
return problems;
}
}
+10
View File
@@ -37,6 +37,16 @@ public interface IBlobStorageService
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
/// <summary>
/// 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
/// <c>overwrite: true</c>, and the backup copy is what survives that. Returns the backup
/// blob's URI, or <c>null</c> when there was nothing to back up, storage is disabled/unconfigured,
/// or the copy failed — logged, never throws.
/// </summary>
Task<Uri?> CopyInvoicePdfToBackupAsync(string invoiceId, string fileName, CancellationToken cancellationToken = default);
/// <summary>
/// Returns <c>true</c> if a blob already exists for the given container/document/filename
/// combination. Used by the startup backfill to skip documents that were already archived.
+12 -4
View File
@@ -2,7 +2,9 @@
/// <summary>
/// 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.
/// </summary>
public interface IComService
{
@@ -14,9 +16,15 @@ public interface IComService
/// <param name="html">HTML body content (signature is appended automatically).</param>
/// <param name="email">Recipient email address.</param>
/// <param name="name">Recipient display name.</param>
/// <param name="attachments">Optional file attachments (filename → content).</param>
/// <param name="attachments">
/// Optional file attachments (filename → content). Validated against the Mailer's limits
/// (<see cref="MailerAttachmentPolicy"/>) before sending; any violation fails the send.
/// </param>
/// <param name="cc">Optional Cc recipient (e.g. a customer's "Zusatzempfänger").</param>
/// <returns><c>true</c> when the email was accepted successfully.</returns>
/// <returns>
/// <c>true</c> only when the Mailer accepted the message <em>and</em> confirmed that every
/// attachment was processed (<c>attachmentsProcessed</c> equals the number sent).
/// </returns>
Task<bool> SendEmailAsync(string reference, string subject, string html,
string email, string name, Dictionary<string, byte[]>? attachments = null, string? cc = null);
@@ -30,7 +38,7 @@ public interface IComService
/// <summary>
/// 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.
/// </summary>
Task<MailerCredentialsCheckResult> VerifyCredentialsAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,32 @@
using OCORE.security;
namespace Fuchs.Services;
/// <summary>
/// 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
/// <c>_BackupAndClearInvoiceFile</c> maintenance tool.
/// </summary>
public interface IInvoiceFileBackupService
{
/// <summary>
/// Backs up the invoice's current stored file (DB row via
/// <c>fds__admin_backupAndClearInvoiceFile</c>, plus a best-effort rename-to-backup of the
/// archived blob) and clears the stored file.
/// </summary>
Task<InvoiceFileBackupResult> BackupAndClearAsync(string id, string userAccountId, DatabaseSecurity dbSec,
CancellationToken cancellationToken = default);
}
/// <summary>Outcome of <see cref="IInvoiceFileBackupService.BackupAndClearAsync"/>.</summary>
public sealed class InvoiceFileBackupResult
{
public bool Success { get; init; }
/// <summary>Machine-readable reason when <see cref="Success"/> is <c>false</c>.</summary>
public string? Reason { get; init; }
/// <summary>The archived blob's backup URI, when the blob rename succeeded (best-effort — can be <c>null</c> even on success).</summary>
public Uri? BlobBackupUri { get; init; }
}
+6 -2
View File
@@ -22,7 +22,11 @@ public interface ISystemStatusService
/// <summary>
/// Sends a test email through the normal <see cref="IComService"/> pipeline, so the
/// dev/test recipient override (<see cref="FuchsEmailSettings.OverrideRecipient"/>) applies
/// exactly as for any other outbound email.
/// exactly as for any other outbound email. With <paramref name="attachPdf"/> (the default)
/// a small letter-layout PDF rendered from the test mail's subject/body is attached, so the
/// Mailer's attachment path (multipart + <c>attachmentsProcessed</c> confirmation, ADR 0028)
/// is exercised too; <c>false</c> sends a plain text-only mail.
/// </summary>
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default);
Task<EmailTestResult> SendTestEmailAsync(string to, string subject, string body,
bool attachPdf = true, CancellationToken cancellationToken = default);
}
+131
View File
@@ -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;
/// <summary>See <see cref="IInvoiceFileBackupService"/>.</summary>
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<InvoiceFileBackupService> _logger;
public InvoiceFileBackupService(Fuchs_intranet intranet, IInvoiceService invoices,
IBlobStorageService blobStorage, ILogger<InvoiceFileBackupService> logger)
{
_intranet = intranet;
_invoices = invoices;
_blobStorage = blobStorage;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
public async Task<InvoiceFileBackupResult> 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<string, object?>("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<string, object?>("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<string, object?>("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<SqlParameter>
{
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<string, object?>("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<string, object?>("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<string, object?>("outcome", "cleared"));
return new InvoiceFileBackupResult { Success = true, BlobBackupUri = blobBackupUri };
}
}
+54
View File
@@ -0,0 +1,54 @@
using OCORE.web.pwmailer;
namespace Fuchs.Services;
/// <summary>
/// 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
/// <see cref="PWMailerMessage.Validate"/>; this adds the limit rules on top — see
/// <see cref="FuchsMailerClient.ValidateMessage"/>. Pure — no I/O — so it is unit-testable.
/// </summary>
public static class MailerAttachmentPolicy
{
/// <summary>
/// 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.
/// </summary>
internal static readonly HashSet<string> 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",
};
/// <summary>Returns every limit rule the attachments violate, in a stable order; empty when acceptable.</summary>
public static IReadOnlyList<string> Validate(IReadOnlyList<PWMailerAttachment> attachments, MailerAttachmentLimits limits)
{
var violations = new List<string>();
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;
}
}
+95 -130
View File
@@ -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;
/// <summary>
/// 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, <c>fds__emaillog</c> audit, telemetry) on top of the OCORE_web
/// <see cref="IPWMailerClientService"/>, 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 <c>ProcessWebComSettings.Enabled</c> is <c>false</c> the service
/// only logs the intended communication without calling the API.
/// </summary>
@@ -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 =
"<p>&nbsp;</p><p style=\"margin:24px 0 16px 0;line-height:140%;\">" +
@@ -36,13 +38,13 @@ public class ProcessWebComService : IComService
Fuchs_intranet intranet,
IOptions<ProcessWebComSettings> settings,
IOptions<FuchsEmailSettings> emailSettings,
IHttpClientFactory httpClientFactory)
IPWMailerClientService mailer)
{
_logger = logger;
_intranet = intranet;
_settings = settings.Value;
_emailSettings = emailSettings.Value;
_httpClientFactory = httpClientFactory;
_mailer = mailer;
}
public async Task<bool> 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<string>();
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<string, byte[]>())
.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<string, byte[]>())
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<string, object?>("success", success));
if (success) FuchsTelemetry.EmailsSent.Add(1);
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("reason", "api-error"));
else FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair<string, object?>("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<MailerVerifyResponse>(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);
}
/// <summary>
@@ -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
+24 -3
View File
@@ -1,12 +1,13 @@
namespace Fuchs.Services;
namespace Fuchs.Services;
/// <summary>
/// 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).
/// </summary>
public class ProcessWebComSettings
{
/// <summary>Base URL of the ProcessWeb API (e.g. "https://api.processweb.de").</summary>
public string BaseUrl { get; set; } = "https://api.processweb.de";
/// <summary>Base URL of the ProcessWeb host (e.g. "https://processweb.de"); endpoints live under <c>/api/mailer/…</c>.</summary>
public string BaseUrl { get; set; } = "https://processweb.de";
/// <summary>Account ID used for HTTP Basic authentication.</summary>
public string AccountId { get; set; } = "";
@@ -23,4 +24,24 @@ public class ProcessWebComSettings
/// Set to <c>true</c> to enable live sending.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// 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.
/// </summary>
public MailerAttachmentLimits Attachments { get; set; } = new();
}
/// <summary>See <see cref="ProcessWebComSettings.Attachments"/>.</summary>
public sealed class MailerAttachmentLimits
{
/// <summary>Maximum size of a single attachment in bytes (contract default: 10 MB).</summary>
public long MaxFileBytes { get; set; } = 10L * 1024 * 1024;
/// <summary>Maximum total attachment size per message in bytes (contract default: 20 MB).</summary>
public long MaxMessageBytes { get; set; } = 20L * 1024 * 1024;
/// <summary>Maximum number of attachments per message (contract default: 10).</summary>
public int MaxCount { get; set; } = 10;
}
+6
View File
@@ -129,4 +129,10 @@ public sealed class EmailTestResult
/// (see <see cref="FuchsEmailSettings.OverrideRecipient"/>); otherwise <c>null</c>.
/// </summary>
public string? OverrideRecipient { get; init; }
/// <summary>
/// File name of the PDF that was attached to the test mail, or <c>null</c> when the mail was
/// sent without an attachment (or never reached the Mailer).
/// </summary>
public string? AttachmentName { get; init; }
}
+63 -5
View File
@@ -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<ProcessWebComSettings> mailer,
IOptions<FuchsEmailSettings> email,
IOptions<AzureBlobStorageSettings> 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
}
}
/// <summary>File name of the PDF attached to a test email when <c>attachPdf</c> is requested.</summary>
internal const string TestPdfAttachmentName = "Fuchs-Intranet-Testmail.pdf";
public async Task<EmailTestResult> 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 <p>.
string html = "<p>" + System.Net.WebUtility.HtmlEncode(body).Replace("\r\n", "\n").Replace("\n", "<br/>") + "</p>";
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<string, byte[]>? attachments = null;
if (attachPdf)
{
try
{
attachments = new Dictionary<string, byte[]>
{
[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,
};
}
/// <summary>
/// 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.
/// </summary>
private async Task<byte[]> 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);
}
}
+1 -1
View File
@@ -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",
+7 -2
View File
@@ -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": ""
+19
View File
@@ -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",
+1 -1
View File
@@ -2,5 +2,5 @@
$fis.notifications.init();
$fis.draft.init();
$fis.ov();
$fis.addAdminMenuButtonsIfAuthorized();
$fis.addAdminMenuButtonsIfAuthorized().then($fis.openModuleFromPath);
});
+29 -8
View File
@@ -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
@@ -36,3 +38,22 @@ $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:<fn>` 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); }
};
+1
View File
@@ -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.'
+4 -3
View File
@@ -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,
@@ -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.'
@@ -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 = $('<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/<id> (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;
@@ -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; }
}
}
@@ -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).'
}
};
+6 -3
View File
@@ -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,
File diff suppressed because one or more lines are too long
+31 -9
View File
@@ -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:<fn>` 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);
});
+2 -2
View File
File diff suppressed because one or more lines are too long
+120
View File
@@ -0,0 +1,120 @@
/* basics */
/* Color SCHEME */
/* #8396bd */
/* #e3e6e6 */
/* #ffffff */
/* #262626 */
/* #262626 */
/* #e3e6e6 */
/* #FFC801 */
/* #ffff4a */
/* media breaks */
/* other */
/* mixin */
/* basics */
/* Color SCHEME */
/* #1b4379 #0033b3 */
/*neu: #56a532 ; alt: rgb(32,144,119);*/
/* 10% sw */
.spcfrm {
padding: 1.5rem;
}
.spcfrm .spc_intro {
margin-bottom: 1rem;
padding: 0.5rem 0.6rem;
background: #f3f6fb;
border-left: 3px solid rgb(27, 67, 121);
font-size: 0.9rem;
color: #444;
}
.spcfrm .spc_grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(24rem, 1fr));
gap: 1rem;
align-items: start;
}
.spcfrm .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;
}
.spcfrm .spc_card_hd {
padding: 0.6rem 0.85rem;
background: rgb(27, 67, 121);
color: #fff;
font-weight: bold;
}
.spcfrm .spc_card_bd {
padding: 0.75rem 0.85rem;
}
.spcfrm .spc_card_desc {
font-size: 0.88rem;
color: #555;
margin-bottom: 0.75rem;
}
.spcfrm .spc_form .spc_field {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.6rem;
}
.spcfrm .spc_form .spc_field label {
font-size: 0.85rem;
color: #333;
font-weight: 600;
}
.spcfrm .spc_form .spc_field input {
padding: 0.3rem 0.45rem;
border: 1px solid #bbb;
border-radius: 0.25rem;
font-size: 0.95rem;
}
.spcfrm .spc_form .spc_field input:invalid {
border-color: #c0341d;
}
.spcfrm .spc_form .spc_field .spc_field_note {
font-size: 0.78rem;
color: #777;
}
.spcfrm .spc_form .spc_actions {
text-align: right;
margin-top: 0.25rem;
}
.spcfrm .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;
}
.spcfrm .spc_btn.spc_btn_primary {
background-color: rgb(86, 165, 50);
border-color: rgb(69.68, 133.688372093, 40.511627907);
color: #fff;
}
.spcfrm .spc_btn:disabled {
opacity: 0.6;
cursor: default;
}
.spcfrm .spc_result {
margin-top: 0.75rem;
padding: 0.5rem 0.6rem;
border-radius: 0.25rem;
font-size: 0.88rem;
word-break: break-word;
}
.spcfrm .spc_result .spc_result_line + .spc_result_line {
margin-top: 0.25rem;
}
.spcfrm .spc_result.spc_result_ok {
background: #e2f3da;
color: #2f6d18;
}
.spcfrm .spc_result.spc_result_err {
background: #f3ded9;
color: #c0341d;
}
+124
View File
@@ -0,0 +1,124 @@
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).'
}
};
/* "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 = $('<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/<id> (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;
+1
View File
@@ -0,0 +1 @@
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.",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)."}},$spc={init2:function(){$ocms.getScript([],(function(){$spc.init3()}))},init3:function(){$fis.cf(!0),$fis.lf(!0),$("#activemodule").text($spt.mdl),$("#topbar").ocmsmenu([]);let e=$$.dc("spcfrm",$("#contentframe"));$$.dc("spc_intro",e).text($spt.intro);let t=$$.dc("spc_grid",e);$.each($spc.cards,(function(e,n){n(t)}))},card:function(e,t,n){let i=$$.dc("spc_card",e),r=$$.dc("spc_card_hd",i);$$.s(t).appendTo(r);let c=$$.dc("spc_card_bd",i);return n&&$$.dc("spc_card_desc",c).text(n),{card:i,body:c,form:$("<form/>",{class:"spc_form",novalidate:"novalidate"}).appendTo(c),result:$$.dc("spc_result",c).hide()}},field:function(e,t,n,i){i=i||{};let r=$$.dc("spc_field",e),c="spc_"+t;$$.lbl(n,{for:c}).appendTo(r);let o=$$.i({id:c,name:t,type:i.type||"text",placeholder:i.placeholder||""}).appendTo(r);return i.required&&o.attr("required","required"),i.pattern&&o.attr("pattern",i.pattern),i.note&&$$.dc("spc_field_note",r).text(i.note),o},showResult:function(e,t,n){e.empty().attr("class","spc_result "+(t?"spc_result_ok":"spc_result_err")),$.each([].concat(n||[]),(function(t,n){n&&$$.dc("spc_result_line",e).text(n)})),e.show()},submit:function(e,t){let n=$$.bbtn(t.button,"spc_btn spc_btn_primary").attr("type","submit").appendTo($$.dc("spc_actions",e.form));e.form.on("submit",(function(i){i.preventDefault();let r=e.form[0];if("function"==typeof r.reportValidity&&!r.reportValidity())return;let c=e.form.serializeObject(!0,{typedvalues:!1});("function"!=typeof t.confirm||confirm(t.confirm(c)))&&(e.result.hide(),n.prop("disabled",!0).text($spt.working),$ocms.postXT($.extend(t.run(c),{success:function(n){let i=t.report(n||{});$spc.showResult(e.result,i.ok,i.lines)},error:function(t){$spc.showResult(e.result,!1,[$spt.failed,t&&t.internalText||""])},complete:function(){n.prop("disabled",!1).text(t.button)}})))}))},cards:[function(e){let t=$spc.card(e,$spt.ifc_title,$spt.ifc_desc);$spc.field(t.form,"id",$spt.ifc_id,{required:!0,placeholder:$spt.ifc_id_ph,note:$spt.ifc_id_note,pattern:"[A-Za-z0-9]{1,10}"}),$spc.submit(t,{button:$spt.ifc_btn,confirm:function(e){return $spt.ifc_confirm.replace("{0}",e.id)},run:function(e){return{url:$ocms.url("admin/backupclearinvoicefile/"+encodeURIComponent((e.id||"").trim())),method:"post",data:{}}},report:function(e){let t=e.backup||{};return!0===t.success?{ok:!0,lines:[$spt.ifc_ok,t.blobBackupUri?$spt.ifc_ok_blob.replace("{0}",t.blobBackupUri):$spt.ifc_ok_noblob]}:{ok:!1,lines:[$spt.ifc_reason[t.reason]||$spt.failed+" ("+(t.reason||"?")+")"]}}})}]},$$spc={init2:$spc.init2,auth:{}};export default $$spc;
+1
View File
@@ -0,0 +1 @@
.spcfrm{padding:1.5rem}.spcfrm .spc_intro{margin-bottom:1rem;padding:.5rem .6rem;background:#f3f6fb;border-left:3px solid #1b4379;font-size:.9rem;color:#444}.spcfrm .spc_grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(24rem,1fr));gap:1rem;align-items:start}.spcfrm .spc_card{border:1px solid #d5d5d5;border-radius:.4rem;background:#fff;box-shadow:0 1px 3px rgba(50,50,50,.12);overflow:hidden}.spcfrm .spc_card_hd{padding:.6rem .85rem;background:#1b4379;color:#fff;font-weight:700}.spcfrm .spc_card_bd{padding:.75rem .85rem}.spcfrm .spc_card_desc{font-size:.88rem;color:#555;margin-bottom:.75rem}.spcfrm .spc_form .spc_field{display:flex;flex-direction:column;gap:.2rem;margin-bottom:.6rem}.spcfrm .spc_form .spc_field label{font-size:.85rem;color:#333;font-weight:600}.spcfrm .spc_form .spc_field input{padding:.3rem .45rem;border:1px solid #bbb;border-radius:.25rem;font-size:.95rem}.spcfrm .spc_form .spc_field input:invalid{border-color:#c0341d}.spcfrm .spc_form .spc_field .spc_field_note{font-size:.78rem;color:#777}.spcfrm .spc_form .spc_actions{text-align:right;margin-top:.25rem}.spcfrm .spc_btn{cursor:pointer;border-radius:.28rem;padding:.3rem .8rem;font-size:.9rem;border:1px solid #ababab;background-color:#fff;color:#333}.spcfrm .spc_btn.spc_btn_primary{background-color:#56a532;border-color:rgb(69.68,133.688372093,40.511627907);color:#fff}.spcfrm .spc_btn:disabled{opacity:.6;cursor:default}.spcfrm .spc_result{margin-top:.75rem;padding:.5rem .6rem;border-radius:.25rem;font-size:.88rem;word-break:break-word}.spcfrm .spc_result .spc_result_line+.spc_result_line{margin-top:.25rem}.spcfrm .spc_result.spc_result_ok{background:#e2f3da;color:#2f6d18}.spcfrm .spc_result.spc_result_err{background:#f3ded9;color:#c0341d}
File diff suppressed because one or more lines are too long
+2
View File
@@ -127,6 +127,7 @@
<Build Include="dbo\Tables\fds__mfr_invoicefiles.sql" />
<Build Include="dbo\Tables\fds__log.sql" />
<Build Include="dbo\Tables\fds__invoices.sql" />
<Build Include="dbo\Tables\fds__invoice_file_backups.sql" />
<Build Include="dbo\Tables\fds__invoice_servicerequests.sql" />
<Build Include="dbo\Tables\fds__invoice_items.sql" />
<Build Include="dbo\Tables\fds__invoice_details.sql" />
@@ -357,6 +358,7 @@
<Build Include="dbo\Stored Procedures\fds__admin_logdebug.sql" />
<Build Include="dbo\Stored Procedures\fds__admin_logdebug_ext.sql" />
<Build Include="dbo\Stored Procedures\fds__admin_getReportCatalog.sql" />
<Build Include="dbo\Stored Procedures\fds__admin_backupAndClearInvoiceFile.sql" />
<Build Include="dbo\Stored Procedures\_BackupAndClearInvoiceFile.sql" />
<Build Include="dbo\User Defined Types\json_data_maxU.sql" />
<Build Include="dbo\User Defined Types\hash_256.sql" />
@@ -0,0 +1,62 @@
-- =============================================
-- Author: Dr. Stefan Ott
-- Create date: 2026-09-17
-- Description: App-facing counterpart to the manual [dbo].[_BackupAndClearInvoiceFile] maintenance
-- tool (see that proc's header comment and Docs/Decisions/0025's "Alternatives
-- considered"). That proc is explicitly manual-only and leaves no application log
-- entry, so it is never called from here — this is a separate proc for the hidden
-- admin/backupclearinvoicefile action (fds_sys > 4 gate, IntranetController.Admin.cs),
-- which logs through the normal ILogger/DB-log-sink pipeline (ADR 0024) instead.
--
-- Copies the invoice's current [file] bytes into [dbo].[fds__invoice_file_backups],
-- then clears [file] so the document regenerates on next read (ADR 0025). Both steps
-- run in one transaction. Only a finalised, non-external invoice qualifies — the same
-- precondition ADR 0025 puts on regeneration: clearing a draft's file would let the
-- next read persist a draft render (fds__setInvoiceFile writes WHERE [file] IS NULL)
-- that then blocks the real final PDF forever; an external (MFR) document cannot be
-- regenerated at all. The calling service checks this too; this is defense in depth.
-- An empty OUTPUT rowset means nothing was backed up/cleared (invoice missing, not
-- finalised, external, or [file] already NULL) — rows-affected can't be used for this
-- because of SET NOCOUNT ON (same reasoning as fds__setInvoiceFile, ADR 0025).
-- =============================================
CREATE PROCEDURE [dbo].[fds__admin_backupAndClearInvoiceFile]
@authuser varchar(25),
@Id varchar(10)
AS
BEGIN
SET NOCOUNT ON;
-- The clear must never commit without its backup row: XACT_ABORT turns any statement-level
-- error inside the transaction into a doomed transaction, and the CATCH rolls back and re-raises
-- so the caller sees an error instead of an OUTPUT row (same pattern as the mfr__updt__* procs).
SET XACT_ABORT ON;
INSERT INTO [dbo].[fds__admin_activity] ([activity], [authuser], [info])
VALUES ('fds__admin_backupAndClearInvoiceFile', @authuser,
(SELECT * FROM (VALUES(@Id)) AS z([id]) FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER));
BEGIN TRY
BEGIN TRANSACTION;
INSERT INTO [dbo].[fds__invoice_file_backups] ([InvoiceId], [DocumentName], [file], [BackedUpBy])
SELECT [Id], [DocumentName], [file], @authuser
FROM [dbo].[fds__invoices]
WHERE [Id] = @Id AND [file] IS NOT NULL
AND [DateFinalized] IS NOT NULL AND ISNULL([IsExternal], 0) = 0;
UPDATE [dbo].[fds__invoices]
SET [file] = NULL
OUTPUT inserted.[Id]
WHERE [Id] = @Id AND [file] IS NOT NULL
AND [DateFinalized] IS NOT NULL AND ISNULL([IsExternal], 0) = 0;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
INSERT INTO dbo.[fds__admin_dberrors] ([UserName], [ErrorNumber], [ErrorState], [ErrorSeverity], [ErrorLine], [ErrorProcedure], [ErrorMessage], [ErrorDateTime])
VALUES (SUSER_SNAME(), ERROR_NUMBER(), ERROR_STATE(), ERROR_SEVERITY(), ERROR_LINE(), ERROR_PROCEDURE(), ERROR_MESSAGE(), GETDATE());
THROW;
END CATCH;
END
@@ -0,0 +1,13 @@
CREATE TABLE [dbo].[fds__invoice_file_backups] (
[uid] BIGINT IDENTITY (1, 1) NOT NULL,
[InvoiceId] VARCHAR (10) NOT NULL,
[DocumentName] VARCHAR (50) NULL,
[file] VARBINARY (MAX) NOT NULL,
[BackedUpAt] DATETIME CONSTRAINT [DF_fds__invoice_file_backups_BackedUpAt] DEFAULT (getutcdate()) NOT NULL,
[BackedUpBy] VARCHAR (25) NULL,
CONSTRAINT [PK_fds__invoice_file_backups] PRIMARY KEY CLUSTERED ([uid] ASC)
);
GO
CREATE NONCLUSTERED INDEX [fds__invoice_file_backups__ix1]
ON [dbo].[fds__invoice_file_backups]([InvoiceId] ASC);
+1 -1
Submodule OCORE updated: 3137b5601a...9f9b2d2220