--- 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.