From 86f6a608602439cb7f71b73a7c13359f5e3e71a2 Mon Sep 17 00:00:00 2001 From: Stefan Date: Wed, 16 Sep 2026 22:31:27 +0200 Subject: [PATCH] feat: implement set-based invoice payment calculations and enhance email logging configuration --- Fuchs.Tests/ProcessWebComServiceTests.cs | 86 +++++++++++++++++++ ...-set-based-and-emaillog-config-contract.md | 74 ++++++++++++++++ Fuchs/Services/ProcessWebComService.cs | 34 +++++++- Fuchs_Database/FuchsDatabase.sqlproj | 5 ++ ..._fis_widgets_set_based_payment_amounts.sql | 31 +++++++ .../fds__fn_InvoicePaymentAmounts.sql | 23 +++++ .../dbo/Functions/fds__fn_unpaidInvoices.sql | 16 ++-- .../dbo/Functions/fds_wgd_lastEmails.sql | 11 ++- 8 files changed, 268 insertions(+), 12 deletions(-) create mode 100644 Fuchs/Docs/Decisions/0026-dashboard-widget-sql-set-based-and-emaillog-config-contract.md create mode 100644 Fuchs_Database/Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql create mode 100644 Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmounts.sql diff --git a/Fuchs.Tests/ProcessWebComServiceTests.cs b/Fuchs.Tests/ProcessWebComServiceTests.cs index 047bb7d..ce4ca4b 100644 --- a/Fuchs.Tests/ProcessWebComServiceTests.cs +++ b/Fuchs.Tests/ProcessWebComServiceTests.cs @@ -70,6 +70,7 @@ public class ProcessWebComServiceTests private sealed class CapturingComService : ProcessWebComService { public readonly List<(bool success, List log)> Entries = new(); + public readonly List Configs = new(); public CapturingComService( IOptions settings, @@ -83,6 +84,7 @@ public class ProcessWebComServiceTests DateTime sent, bool success, IEnumerable errors) { Entries.Add((success, errors.ToList())); + Configs.Add(config); return Task.CompletedTask; } } @@ -267,6 +269,90 @@ public class ProcessWebComServiceTests Assert.Equal(when, pl.Single(p => p.ParameterName == "@DateSent").Value); } + // ── Audit-log config: fds__emaillog.config must be the JSON fds_wgd_lastEmails parses ─ + // Regression: the service logged config = "" on every path; OPENJSON('') throws, which took + // the "last emails" dashboard widget down and lost who/what/attachments from the audit trail. + [Fact] + public void BuildAuditConfig_FullInput_ProducesLegacyShapeWithoutBody() + { + var files = new Dictionary { ["Rechnung R2026-0119.pdf"] = [1, 2], ["x.xml"] = [3] }; + + string json = ProcessWebComService.BuildAuditConfig( + "SanitärFuchs - Rechnung", "buchhaltung@example.de", "Buchhaltung Erne", "chef@example.de", files.Keys); + + var o = JObject.Parse(json); + Assert.Equal("SanitärFuchs - Rechnung", (string?)o["subject"]); + Assert.Equal("", (string?)o["from"]); + Assert.Equal(new[] { "\"Buchhaltung Erne\" " }, o["to"]!.Select(t => (string?)t)); + Assert.Equal(new[] { "chef@example.de" }, o["cc"]!.Select(t => (string?)t)); + Assert.Empty(o["bcc"]!); + Assert.Equal(new[] { "Rechnung R2026-0119.pdf", "x.xml" }, o["attachments"]!.Select(t => (string?)t)); + Assert.Null(o["body"]); // never the HTML body + Assert.DoesNotContain("AQI=", json); // never attachment bytes (base64 of 1,2) + } + + [Theory] + [InlineData(null, "a@b.de", "a@b.de")] // no name → bare address + [InlineData("", "a@b.de", "a@b.de")] + [InlineData(" ", "a@b.de", "a@b.de")] + [InlineData("Ann", "a@b.de", "\"Ann\" ")] // name → quoted display form the widget unwraps + [InlineData(" Ann ", "a@b.de", "\"Ann\" ")] + public void BuildAuditConfig_RecipientName_FormatsToArrayEntry(string? name, string email, string expected) + { + var o = JObject.Parse(ProcessWebComService.BuildAuditConfig("S", email, name, null, null)); + + Assert.Equal(new[] { expected }, o["to"]!.Select(t => (string?)t)); + Assert.Empty(o["cc"]!); + Assert.Empty(o["attachments"]!); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void BuildAuditConfig_NullOrEmptySubject_StillValidJson(string? subject) + { + var o = JObject.Parse(ProcessWebComService.BuildAuditConfig(subject!, "a@b.de", null, null, null)); + Assert.Equal("", (string?)o["subject"]); + } + + public static IEnumerable AllAuditPaths() => + [ + [ "success", HttpStatusCode.OK, true, "kunde@example.de" ], + [ "api-error", HttpStatusCode.InternalServerError, true, "kunde@example.de" ], + [ "disabled", HttpStatusCode.OK, false, "kunde@example.de" ], + [ "invalid-email", HttpStatusCode.OK, true, "not-an-email" ], + ]; + + [Theory] + [MemberData(nameof(AllAuditPaths))] + public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments( + string path, HttpStatusCode status, bool enabled, string email) + { + var svc = CreateCapturing(new StubHandler(status), enabled: enabled); + var att = new Dictionary { ["Mahnung.pdf"] = [9, 9] }; + + await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "

x

", email, "Kunde", att); + + string config = Assert.Single(svc.Configs); + var o = JObject.Parse(config); // was "" → OPENJSON error in fds_wgd_lastEmails + Assert.Equal("Subj " + path, (string?)o["subject"]); + Assert.Equal(new[] { $"\"Kunde\" <{email}>" }, o["to"]!.Select(t => (string?)t)); + Assert.Equal(new[] { "Mahnung.pdf" }, o["attachments"]!.Select(t => (string?)t)); + } + + [Fact] + public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject() + { + var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), overrideRecipient: "dev-inbox@example.test"); + + await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "

x

", "kunde@example.de", "Kunde", null, cc: "cc@example.de"); + + var o = JObject.Parse(Assert.Single(svc.Configs)); + Assert.Equal(new[] { "\"Kunde\" " }, o["to"]!.Select(t => (string?)t)); // where it really went + Assert.Empty(o["cc"]!); // cc is dropped by the override + Assert.StartsWith("[DEV → kunde@example.de] ", (string?)o["subject"]); // original recipient stays traceable + } + // ── Dev/test recipient override safety net ───────────────────────────────── [Fact] public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress() diff --git a/Fuchs/Docs/Decisions/0026-dashboard-widget-sql-set-based-and-emaillog-config-contract.md b/Fuchs/Docs/Decisions/0026-dashboard-widget-sql-set-based-and-emaillog-config-contract.md new file mode 100644 index 0000000..4247bac --- /dev/null +++ b/Fuchs/Docs/Decisions/0026-dashboard-widget-sql-set-based-and-emaillog-config-contract.md @@ -0,0 +1,74 @@ +--- +status: Accepted +date: 2026-09-16 +applyTo: + - "Fuchs/Services/FuchsWidgetService.cs" + - "Fuchs/Services/ProcessWebComService.cs" + - "Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql" + - "Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmount*.sql" + - "Fuchs_Database/dbo/Functions/fds_wgd_*.sql" + - "Fuchs_Database/dbo/Functions/fds_wdg_*.sql" + - "Fuchs_Database/dbo/Stored Procedures/fds__logEmail.sql" + - "Fuchs_Database/Scripts/**" +supersededBy: "" +--- + +# 0026 — Dashboard widget SQL is set-based (no per-row scalar UDFs); `fds__emaillog.config` is a JSON contract + +## Context +Two dashboard widgets were failing in the DB log (`fds__debuglog`, 2026-09-16): + +1. **`wdg_invoices_overdue_sum`** timed out after the widget command timeout of 90 s. Its SQL called + `[dbo].[fds__fn_InvoicePaymentAmount](Id)` once per row of `fds__fn_unpaidInvoices()`. That scalar + UDF declares a table variable, so SQL Server 2019 cannot inline it (`sys.sql_modules.is_inlineable = 0`) + and re-executes the multi-statement TVF `fds__fn_bankingtransactions` — a full pass over + ~11 k banking rows plus a `STRING_SPLIT` cross-apply over the settings heap — for **every** invoice: + ≈0.5 s × 187 unpaid invoices ≈ 100 s measured. `wdg_unbez_akonto` had the same pattern (≈5 s, + hidden only by `daycache = 1`). The same total computed set-based (one TVF pass, `GROUP BY InvId`, + joined to the invoices) takes < 0.5 s and returns the identical figure. +2. **`wgd_lastEmails`** raised *JSON text is not properly formatted*. `fds_wgd_lastEmails` parses + `fds__emaillog.config` with `OPENJSON`/`JSON_QUERY`, but `ProcessWebComService` — the replacement + for the legacy mailer — wrote `config = ''` on every path. One such row was enough to error the + whole widget, and the audit trail had lost recipient/subject/attachment names for every email sent + through the new service. + +Widget definitions are **data** (`fis_widgets.sql`), not schema, so there is no compile-time check +that a widget's SQL is cheap or that the columns it parses exist. + +## Decision +- **Widget SQL and the functions backing it are set-based.** Per-invoice payment totals come from the + inline TVF `[dbo].[fds__fn_InvoicePaymentAmounts]()` (one banking pass, grouped by `InvId`) or from + the `[PaymentAmount]`/`[OpenAmount]` columns `fds__fn_unpaidInvoices()` now exposes. The scalar + `fds__fn_InvoicePaymentAmount(_full)` remains for single-invoice callers (invoice/reminder + preparation, per-row `paymentstatus = 'p'` cells in the invoice lists) but must not be called + per row from a widget or any other query that iterates a whole invoice set. +- **`fds__emaillog.config` is a JSON contract**, written by `ProcessWebComService.BuildAuditConfig` + in the legacy mailer's shape — `{subject, from, to[], cc[], bcc[], attachments[]}` with addresses + as `"Name" ` and attachments as file names only (never the body, never attachment bytes) — + on **every** audit path (success, API error, exception, invalid address, service disabled). SQL + consumers of the column must still tolerate non-JSON text (`IIF(ISJSON(x)=1, x, N'{}')` before + parsing), because `config` is `NOT NULL` and older rows/other writers may hold `''`. +- **Widget-data migrations live in `Fuchs_Database/Scripts/`** as idempotent, dated T-SQL files + (`` in the sqlproj, not part of the schema build) and are run once per environment + after the schema deploy they depend on. + +## Consequences +- `wdg_invoices_overdue_sum` 101.9 s → 0.45 s, `wdg_unbez_akonto` 4.7 s → 0.4 s, `wgd_lastEmails` + error → 15 ms (dev, 2026-09-16). The widget command timeout stays at 90 s; it is a guard, not a + budget. +- Adding a scalar UDF call inside a widget's `SELECT`/`WHERE` over `fds__invoices` or the + `fds__fn_unpaidInvoices()` set is a regression of this decision. Prefer exposing a set-based column + on the TVF, as done for `PaymentAmount`. +- `ProcessWebComServiceTests` assert the config contract on every audit path; changing the shape + requires updating `fds_wgd_lastEmails` in the same change. +- The `Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql` migration must be run on + Production after the schema deploy (it is a no-op once applied). + +## Alternatives considered +- **Raise the widget `CommandTimeout`** — masks an O(N) re-scan; the cost grows with every unpaid + invoice and every banking upload, and it would still block a dashboard load for minutes. +- **Convert `fds__fn_InvoicePaymentAmount` into an inlineable scalar UDF** — impossible while it + needs a TVP (`fds__tt__idlist_vchar`) to call the banking TVF; rewriting the banking TVF as an + inline TVF is a larger change to a function shared with the banking-assignment UI. +- **Fix the bad `fds__emaillog` row and leave the widget function strict** — the next `''` row (any + writer, any environment) would take the widget down again; tolerance belongs in the reader. diff --git a/Fuchs/Services/ProcessWebComService.cs b/Fuchs/Services/ProcessWebComService.cs index 6f35e2e..66c3fb0 100644 --- a/Fuchs/Services/ProcessWebComService.cs +++ b/Fuchs/Services/ProcessWebComService.cs @@ -74,8 +74,8 @@ public class ProcessWebComService : IComService FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair("reason", "invalid-email")); act?.SetStatus(ActivityStatusCode.Error, "invalid-email"); // Every attempt is audit-logged, including this early rejection (previously it returned unlogged). - await WriteAuditLogAsync(reference, "", "", default, false, - [$"Invalid recipient email address: '{email}'"]); + await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys), + default, false, [$"Invalid recipient email address: '{email}'"]); return false; } @@ -86,7 +86,8 @@ public class ProcessWebComService : IComService _logger.LogInformation( "[ComService DISABLED] Would send email ref={Reference} subject='{Subject}' to={Email}", reference, subject, email); - await WriteAuditLogAsync(reference, "", "", default, false, ["Service disabled – email not sent"]); + await WriteAuditLogAsync(reference, "", BuildAuditConfig(subject, email, name, cc, attachments?.Keys), + default, false, ["Service disabled – email not sent"]); FuchsTelemetry.EmailsFailed.Add(1, new KeyValuePair("reason", "disabled")); return false; } @@ -153,7 +154,8 @@ public class ProcessWebComService : IComService _logger.LogInformation("SendEmailAsync ref={Reference} success={Success} in {Ms} ms", reference, success, sw.ElapsedMilliseconds); - await WriteAuditLogAsync(reference, messageId, "", success ? DateTime.UtcNow : default, success, errors); + await WriteAuditLogAsync(reference, messageId, BuildAuditConfig(subject, email, name, cc, attachments?.Keys), + success ? DateTime.UtcNow : default, success, errors); return success; } @@ -308,6 +310,30 @@ public class ProcessWebComService : IComService } } + /// + /// Serialises the audit config stored in fds__emaillog.config: who the mail went + /// to, its subject and the attachment names — never the body or attachment bytes. + /// The shape (subject/from/to[]/cc[]/bcc[]/attachments[], + /// addresses as "Name" <email>) is the one the legacy mailer wrote and that + /// [dbo].[fds_wgd_lastEmails] reads back with OPENJSON; an empty string here + /// made that dashboard widget fail for every row the new service logged. + /// Pure so the contract can be unit-tested without a database. + /// + internal static string BuildAuditConfig(string subject, string email, string? name, string? cc, + IEnumerable? attachmentNames) => + JsonConvert.SerializeObject(new + { + subject = subject ?? "", + from = "", + to = new[] { FormatAddress(email, name) }, + cc = string.IsNullOrWhiteSpace(cc) ? Array.Empty() : new[] { cc.Trim() }, + bcc = Array.Empty(), + attachments = (attachmentNames ?? []).ToArray() + }); + + private static string FormatAddress(string email, string? name) => + string.IsNullOrWhiteSpace(name) ? (email ?? "") : $"\"{name.Trim()}\" <{email}>"; + /// /// Builds the parameter list for [dbo].[fds__logEmail]. fds__emaillog declares /// config and DateSent as NOT NULL, so neither may be sent as NULL: diff --git a/Fuchs_Database/FuchsDatabase.sqlproj b/Fuchs_Database/FuchsDatabase.sqlproj index a313bc4..b7a48fd 100644 --- a/Fuchs_Database/FuchsDatabase.sqlproj +++ b/Fuchs_Database/FuchsDatabase.sqlproj @@ -61,6 +61,7 @@ + @@ -199,6 +200,7 @@ + @@ -405,4 +407,7 @@ + + + \ No newline at end of file diff --git a/Fuchs_Database/Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql b/Fuchs_Database/Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql new file mode 100644 index 0000000..eff9d11 --- /dev/null +++ b/Fuchs_Database/Scripts/2026-09-16_fis_widgets_set_based_payment_amounts.sql @@ -0,0 +1,31 @@ +-- ============================================= +-- One-off data migration (fis_widgets rows are data, not schema — run once per environment +-- after deploying the schema that adds [dbo].[fds__fn_InvoicePaymentAmounts] and the +-- [PaymentAmount]/[OpenAmount] columns of [dbo].[fds__fn_unpaidInvoices]). +-- +-- Both widgets called the scalar [fds__fn_InvoicePaymentAmount](Id) once per invoice row. That +-- function declares a table variable, so SQL Server cannot inline it and re-runs the whole +-- banking TVF per row (~0.5 s each): wdg_invoices_overdue_sum took ~100 s over 187 unpaid +-- invoices and timed out the 90 s widget command; wdg_unbez_akonto took ~5 s (hidden by daycache). +-- Set-based replacements give identical totals in < 0.5 s. +-- Idempotent: each UPDATE only rewrites the old text and is a no-op once applied. +-- ============================================= +SET NOCOUNT ON; + +UPDATE [dbo].[fis_widgets] + SET [sql] = REPLACE([sql], N'[invoicebalance] - [dbo].[fds__fn_InvoicePaymentAmount](Id)', N'[OpenAmount]') + WHERE [short_name] = 'wdg_invoices_overdue_sum' + AND [sql] LIKE N'%[[]dbo].[[]fds__fn_InvoicePaymentAmount](Id)%'; +PRINT CONCAT('wdg_invoices_overdue_sum rows updated: ', @@ROWCOUNT); + +UPDATE [dbo].[fis_widgets] + SET [sql] = REPLACE(REPLACE([sql] + , N'[dbo].[fds__fn_InvoicePaymentAmount]([id])', N'ISNULL(pay.[Amount], 0.0)') + , N'FROM [dbo].[fds__invoices] as i where', N'FROM [dbo].[fds__invoices] as i LEFT JOIN [dbo].[fds__fn_InvoicePaymentAmounts]() as pay ON pay.[InvId] = i.[id] where') + WHERE [short_name] = 'wdg_unbez_akonto' + AND [sql] LIKE N'%[[]dbo].[[]fds__fn_InvoicePaymentAmount]([[]id])%' + AND [sql] LIKE N'%FROM [[]dbo].[[]fds__invoices] as i where%'; +PRINT CONCAT('wdg_unbez_akonto rows updated: ', @@ROWCOUNT); + +-- Verify: nothing on the dashboard still calls the per-row scalar function. +SELECT [short_name], [sql] FROM [dbo].[fis_widgets] WHERE [sql] LIKE N'%fds__fn_InvoicePaymentAmount](%'; diff --git a/Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmounts.sql b/Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmounts.sql new file mode 100644 index 0000000..8357c32 --- /dev/null +++ b/Fuchs_Database/dbo/Functions/fds__fn_InvoicePaymentAmounts.sql @@ -0,0 +1,23 @@ +-- ============================================= +-- Description: Set-based counterpart of the scalar [fds__fn_InvoicePaymentAmount]: +-- returns the summed (non-storno) banking payments for EVERY invoice in one pass +-- over [fds__fn_bankingtransactions]. Join it instead of calling the scalar +-- function per row — the scalar one declares a table variable, so SQL Server +-- cannot inline it and re-runs the whole banking TVF for each invoice +-- (~0.5 s × N rows; the overdue-sum widget timed out at 90 s with 187 rows). +-- Same semantics as the scalar version with an empty id list: all invoices, +-- no date filter, storno credits NOT included (see *_full for those). +-- ============================================= +CREATE FUNCTION [dbo].[fds__fn_InvoicePaymentAmounts] +( +) +RETURNS TABLE +AS +RETURN +( + SELECT [InvId] = bs.[InvId] + ,[Amount] = CAST(SUM(ISNULL(bs.[Amount], 0.0)) as numeric(9,2)) + FROM [dbo].[fds__fn_bankingtransactions](NULL, NULL, NULL, NULL, DEFAULT) as bs + WHERE bs.[InvId] IS NOT NULL + GROUP BY bs.[InvId] +) diff --git a/Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql b/Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql index b417fd8..8dd226f 100644 --- a/Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql +++ b/Fuchs_Database/dbo/Functions/fds__fn_unpaidInvoices.sql @@ -1,7 +1,9 @@ -- ============================================= --- Author: --- Create date: --- Description: +-- Description: Finalized, not-paid, not-canceled, not-storno'd invoices with a balance > 0. +-- [PaymentAmount] / [OpenAmount] come from one set-based pass over the banking +-- transactions ([fds__fn_InvoicePaymentAmounts]) — use them instead of calling the +-- scalar [fds__fn_InvoicePaymentAmount](Id) per row, which re-runs the whole +-- banking TVF for every invoice and timed out the dashboard widgets. -- ============================================= CREATE FUNCTION [dbo].[fds__fn_unpaidInvoices] ( @@ -28,6 +30,10 @@ RETURN and InvoiceBalance > 0 AND NOT exists (SELECT 0 FROM [dbo].[fds__invoice_details] as d where d.[StornoTo_InvId] = iv.[id]) ) - SELECT * /*[Rechnung] = [invoiceId], [Name] = [InvoiceTitle] + CHAR(10) + ' (' + (SELECT TOP(1) c.[name] FROM [dbo].[mfr__companies] as c WHERE inv.[customerid] = c.[id]) + ')', [Betrag (net)] = FORMAT([invoicebalance], '#0.00€', 'de'), [Datum] = FORMAT([DateFinalized], 'dd.MM.yy' , 'de'), [Zahlungen] = FORMAT([dbo].[fds__fn_InvoicePaymentAmount]([id]), '#0.00€', 'de'), [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized], [DateSent]) */ + SELECT inv.* + ,[PaymentAmount] = ISNULL(pay.[Amount], 0.0) + ,[OpenAmount] = CAST(inv.[InvoiceBalance] - ISNULL(pay.[Amount], 0.0) as numeric(10,2)) + /*[Rechnung] = [invoiceId], [Name] = [InvoiceTitle] + CHAR(10) + ' (' + (SELECT TOP(1) c.[name] FROM [dbo].[mfr__companies] as c WHERE inv.[customerid] = c.[id]) + ')', [Betrag (net)] = FORMAT([invoicebalance], '#0.00€', 'de'), [Datum] = FORMAT([DateFinalized], 'dd.MM.yy' , 'de'), [Zahlungen] = FORMAT([dbo].[fds__fn_InvoicePaymentAmount]([id]), '#0.00€', 'de'), [order] = ROW_NUMBER() OVER (ORDER BY [DateFinalized], [DateSent]) */ FROM inv -) \ No newline at end of file + LEFT JOIN [dbo].[fds__fn_InvoicePaymentAmounts]() as pay ON pay.[InvId] = inv.[Id] +) \ No newline at end of file diff --git a/Fuchs_Database/dbo/Functions/fds_wgd_lastEmails.sql b/Fuchs_Database/dbo/Functions/fds_wgd_lastEmails.sql index 2318046..ead849d 100644 --- a/Fuchs_Database/dbo/Functions/fds_wgd_lastEmails.sql +++ b/Fuchs_Database/dbo/Functions/fds_wgd_lastEmails.sql @@ -2,6 +2,10 @@ returns table as return +-- [config] is NOT NULL but a logging path may have stored '' (or otherwise non-JSON text). +-- OPENJSON/JSON_QUERY raise on that and took the whole widget down, so the JSON is normalised +-- once (invalid -> '{}') before anything parses it; a WHERE ISJSON() alone is not guaranteed to +-- be evaluated ahead of the CROSS APPLY. SELECT TOP (@count) [Betreff] = oj.[subject], [Empfänger] = (SELECT STRING_AGG( @@ -9,9 +13,10 @@ SELECT TOP (@count) THEN SUBSTRING([value], CHARINDEX('<', [value]) + 1, CHARINDEX('>', [value]) - CHARINDEX('<', [value]) - 1) ELSE [value] END, ', ') - FROM OPENJSON(JSON_QUERY(el.[config], '$.to'))), - [attachments] = (SELECT STRING_AGG([value], ', ') FROM OPENJSON(JSON_QUERY(el.[config], '$.attachments'))), + FROM OPENJSON(JSON_QUERY(j.[cfg], '$.to'))), + [attachments] = (SELECT STRING_AGG([value], ', ') FROM OPENJSON(JSON_QUERY(j.[cfg], '$.attachments'))), [gesendet] = FORMAT(el.DateSent, 'dd.MM.yy', 'de-DE') FROM [dbo].[fds__emaillog] as el - CROSS APPLY OPENJSON(el.[config], '$') WITH ( [subject] nvarchar(255) '$.subject' ) as oj + CROSS APPLY (SELECT [cfg] = IIF(ISJSON(el.[config]) = 1, el.[config], N'{}')) as j + CROSS APPLY OPENJSON(j.[cfg], '$') WITH ( [subject] nvarchar(255) '$.subject' ) as oj order by [DateSent] desc \ No newline at end of file