Add Azure Blob Storage archive & email safety net

- Add AzureBlobStorageService, DocumentArchiveSyncService, and related config for secondary PDF archiving of invoices/reminders
- Add SQL procs and schema changes for archive backfill
- Update invoice/reminder services to upload PDFs to blob storage
- Add telemetry counters and unit tests for blob storage/archive logic
- Add Fuchs:Email:OverrideRecipient config and enforce dev/test email redirect in ProcessWebComService, with tests
- Improve JS date parsing (German formats), stricter JSON date detection
- Increase widget SQL timeouts, update dependencies, docs, and project files
This commit is contained in:
Stefan
2026-07-03 10:15:04 +02:00
parent 3035ef1719
commit c3395bc83c
47 changed files with 1723 additions and 101 deletions
+147
View File
@@ -0,0 +1,147 @@
using System.Diagnostics;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Fuchs.Observability;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Fuchs.Services;
/// <summary>
/// Archives finalized invoice/reminder PDFs (and, via <see cref="UploadDocumentAsync"/>, any
/// future file-bearing document type) to Azure Blob Storage, in addition to the existing SQL
/// Server storage (see <see cref="InvoiceService"/> and <see cref="ReminderService"/>). This is
/// a best-effort secondary archive: when <see cref="AzureBlobStorageSettings.Enabled"/> is
/// <c>false</c> (default) or no connection string is configured, uploads are skipped and only
/// logged; upload failures are caught and logged rather than propagated, so a missing or
/// unreachable storage account never breaks invoice/reminder finalization.
/// </summary>
public class AzureBlobStorageService : IBlobStorageService
{
private readonly ILogger<AzureBlobStorageService> _logger;
private readonly AzureBlobStorageSettings _settings;
private readonly BlobServiceClient? _client;
public AzureBlobStorageService(IConfiguration configuration,
IOptions<AzureBlobStorageSettings> settings,
ILogger<AzureBlobStorageService> logger)
{
_logger = logger;
_settings = settings.Value;
if (_settings.Enabled)
{
string? connectionString = configuration.GetConnectionString("AzureBlobStorage_ConnectionString");
if (!string.IsNullOrWhiteSpace(connectionString) && connectionString != "MANAGED_BY_KEYVAULT")
{
_client = new BlobServiceClient(connectionString);
}
else
{
_logger.LogWarning(
"AzureBlobStorageService is enabled but ConnectionStrings:AzureBlobStorage_ConnectionString " +
"is not configured — uploads will be skipped.");
}
}
}
/// <summary>Test-only constructor allowing an already-built (typically mocked) client to be injected.</summary>
internal AzureBlobStorageService(BlobServiceClient? client, AzureBlobStorageSettings settings,
ILogger<AzureBlobStorageService> logger)
{
_client = client;
_settings = settings;
_logger = logger;
}
public Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
=> UploadAsync("invoice", _settings.InvoiceContainer, invoiceId, fileName, content, sourceRow, cancellationToken);
public Task<Uri?> UploadReminderPdfAsync(string reminderId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
=> UploadAsync("reminder", _settings.ReminderContainer, reminderId, fileName, content, sourceRow, cancellationToken);
public Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default)
=> UploadAsync(category, containerName, documentId, fileName, content, sourceRow, cancellationToken);
public async Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default)
{
if (_client == null) return false;
string blobName = BuildBlobName(documentId, fileName);
try
{
var containerClient = _client.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(blobName);
Response<bool> response = await blobClient.ExistsAsync(cancellationToken);
return response.Value;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Blob existence check failed for {Container}/{Blob} — treating as not archived.",
containerName, blobName);
return false;
}
}
private static string BuildBlobName(string documentId, string fileName) =>
$"{documentId}/{(string.IsNullOrWhiteSpace(fileName) ? $"{documentId}.pdf" : fileName)}";
private async Task<Uri?> UploadAsync(string category, string containerName, string documentId,
string fileName, byte[] content, IReadOnlyDictionary<string, object?>? sourceRow, CancellationToken cancellationToken)
{
if (_client == null)
{
_logger.LogDebug("Blob upload skipped for {Category} {Id} — storage disabled/unconfigured.", category, documentId);
return null;
}
if (content.Length == 0)
{
_logger.LogWarning("Blob upload skipped for {Category} {Id} — empty content.", category, documentId);
return null;
}
using var act = FuchsTelemetry.StartActivity("blobstorage.upload");
act?.SetTag("fuchs.blobstorage.category", category);
act?.SetTag("fuchs.blobstorage.id", documentId);
string blobName = BuildBlobName(documentId, fileName);
Dictionary<string, string>? metadata = sourceRow != null
? DocumentMetadataBuilder.Build(sourceRow, _settings.MetadataFields)
: null;
try
{
var containerClient = _client.GetBlobContainerClient(containerName);
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
var blobClient = containerClient.GetBlobClient(blobName);
using var stream = new MemoryStream(content, writable: false);
if (metadata is { Count: > 0 })
{
var options = new BlobUploadOptions { Metadata = metadata };
await blobClient.UploadAsync(stream, options, cancellationToken);
}
else
{
await blobClient.UploadAsync(stream, overwrite: true, cancellationToken);
}
FuchsTelemetry.BlobUploadsSucceeded.Add(1, new KeyValuePair<string, object?>("category", category));
_logger.LogInformation("Uploaded {Category} {Id} to container {Container} as {Blob}.",
category, documentId, containerName, blobName);
return blobClient.Uri;
}
catch (Exception ex)
{
FuchsTelemetry.BlobUploadsFailed.Add(1, new KeyValuePair<string, object?>("category", category));
act?.SetStatus(ActivityStatusCode.Error, ex.Message);
_logger.LogError(ex, "Blob upload failed for {Category} {Id} in container {Container}.",
category, documentId, containerName);
return null;
}
}
}
@@ -0,0 +1,33 @@
namespace Fuchs.Services;
/// <summary>
/// Azure Blob Storage settings, bound from appsettings.json → "Fuchs:AzureStorage".
/// The storage account connection string itself is a secret and therefore lives
/// under the standard <c>ConnectionStrings</c> key (see <see cref="AzureBlobStorageService"/>,
/// which reads it via <c>IConfiguration.GetConnectionString("AzureBlobStorage_ConnectionString")</c>)
/// instead of being bound here.
/// </summary>
public class AzureBlobStorageSettings
{
/// <summary>
/// When <c>false</c> (default) blob uploads are skipped and only logged, so the
/// feature is opt-in and never impacts environments that haven't configured a
/// storage account + Key Vault secret yet. Set to <c>true</c> to enable archiving.
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>Blob container that stores finalized invoice PDFs.</summary>
public string InvoiceContainer { get; set; } = "fuchs-invoices";
/// <summary>Blob container that stores finalized reminder PDFs.</summary>
public string ReminderContainer { get; set; } = "fuchs-reminders";
/// <summary>
/// Column/property names considered when building the per-blob metadata dictionary
/// (see <see cref="DocumentMetadataBuilder"/>). Not every document type has every
/// column: fields absent from a given source row are skipped entirely, while fields
/// that are present but hold an empty value are still stored as an empty string.
/// </summary>
public List<string> MetadataFields { get; set; } =
new() { "Id", "Version", "InvoiceId", "InvoiceTitle", "InvId", "DocumentName", "file_guid" };
}
@@ -0,0 +1,157 @@
using System.Data;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using static OCORE.commons;
using static OCORE.SQL.sql;
namespace Fuchs.Services;
/// <summary>
/// Startup backfill for the Azure Blob Storage secondary archive (see <see cref="AzureBlobStorageService"/>).
/// When <see cref="AzureBlobStorageSettings.Enabled"/> is <c>true</c>, this one-shot background task
/// enumerates every invoice/reminder that already has a file stored in SQL Server
/// (<c>fds__getInvoiceFiles_ForBlobArchive</c> / <c>fds__getReminderFiles_ForBlobArchive</c>), skips
/// documents already archived (<see cref="IBlobStorageService.ExistsAsync"/>), and uploads the rest —
/// fetching bytes lazily via <c>fds__getInvoiceFileContent</c> / <c>fds__getReminderFileContent</c> so the
/// enumeration query itself stays lightweight (no VARBINARY column). New invoices/reminders created after
/// startup are archived inline by <see cref="InvoiceService"/> / <see cref="ReminderService"/>; this service
/// only covers historical documents that predate the feature being enabled.
/// Runs once at startup (not periodic) and never throws: failures are logged so a database or storage
/// hiccup during startup can never prevent the app from serving requests.
/// </summary>
public class DocumentArchiveSyncService : BackgroundService
{
private const int BackfillConcurrency = 4;
private readonly Fuchs_intranet _intranet;
private readonly IBlobStorageService _blobStorage;
private readonly AzureBlobStorageSettings _settings;
private readonly ILogger<DocumentArchiveSyncService> _logger;
public DocumentArchiveSyncService(Fuchs_intranet intranet, IBlobStorageService blobStorage,
IOptions<AzureBlobStorageSettings> settings, ILogger<DocumentArchiveSyncService> logger)
{
_intranet = intranet;
_blobStorage = blobStorage;
_settings = settings.Value;
_logger = logger;
}
private string Conn => _intranet.Intranet__SQLConnectionString;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!_settings.Enabled)
{
_logger.LogDebug("DocumentArchiveSyncService skipped — Fuchs:AzureStorage:Enabled is false.");
return;
}
using var act = FuchsTelemetry.StartActivity("blobstorage.backfill");
_logger.LogInformation("DocumentArchiveSyncService starting startup backfill.");
try
{
int invoices = await SyncInvoicesAsync(stoppingToken);
int reminders = await SyncRemindersAsync(stoppingToken);
_logger.LogInformation(
"DocumentArchiveSyncService completed: {Invoices} invoice(s), {Reminders} reminder(s) newly archived.",
invoices, reminders);
}
catch (OperationCanceledException)
{
_logger.LogWarning("DocumentArchiveSyncService backfill cancelled (application shutting down).");
}
catch (Exception ex)
{
_logger.LogError(ex, "DocumentArchiveSyncService backfill failed.");
}
}
private async Task<int> SyncInvoicesAsync(CancellationToken cancellationToken)
{
var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__getInvoiceFiles_ForBlobArchive];",
Conn, Security: _intranet.GetDbSecurity(), options: new FIS_SQLOptions());
if (dt.Count == 0) return 0;
int archived = 0;
var rows = dt.DataTable.Rows.Cast<DataRow>().ToList();
await Parallel.ForEachAsync(rows,
new ParallelOptions { MaxDegreeOfParallelism = BackfillConcurrency, CancellationToken = cancellationToken },
async (row, ct) =>
{
string id = row.nz("Id");
if (string.IsNullOrEmpty(id)) return;
try
{
string fileName = row.nz("DocumentName").ne($"Rechnung_{id}.pdf");
if (await _blobStorage.ExistsAsync(_settings.InvoiceContainer, id, fileName, ct))
return;
byte[]? content = await GetFileContentAsync(
"EXECUTE [dbo].[fds__getInvoiceFileContent] @Id;", id);
if (content is not { Length: > 0 }) return;
var uri = await _blobStorage.UploadInvoicePdfAsync(
id, fileName, content, row.toObjectDictionary(), ct);
if (uri != null) Interlocked.Increment(ref archived);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Invoice backfill failed for {Id} — skipping.", id);
}
});
return archived;
}
private async Task<int> SyncRemindersAsync(CancellationToken cancellationToken)
{
var dt = await getSQLDatatable_async(
"EXECUTE [dbo].[fds__getReminderFiles_ForBlobArchive];",
Conn, Security: _intranet.GetDbSecurity(), options: new FIS_SQLOptions());
if (dt.Count == 0) return 0;
int archived = 0;
var rows = dt.DataTable.Rows.Cast<DataRow>().ToList();
await Parallel.ForEachAsync(rows,
new ParallelOptions { MaxDegreeOfParallelism = BackfillConcurrency, CancellationToken = cancellationToken },
async (row, ct) =>
{
string id = row.nz("Id");
if (string.IsNullOrEmpty(id)) return;
try
{
string fileName = row.nz("DocumentName").ne($"Zahlungserinnerung_{id}.pdf");
if (await _blobStorage.ExistsAsync(_settings.ReminderContainer, id, fileName, ct))
return;
byte[]? content = await GetFileContentAsync(
"EXECUTE [dbo].[fds__getReminderFileContent] @Id;", id);
if (content is not { Length: > 0 }) return;
var uri = await _blobStorage.UploadReminderPdfAsync(
id, fileName, content, row.toObjectDictionary(), ct);
if (uri != null) Interlocked.Increment(ref archived);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Reminder backfill failed for {Id} — skipping.", id);
}
});
return archived;
}
private async Task<byte[]?> GetFileContentAsync(string sql, string id)
{
var pl = new List<SqlParameter> { SQL_VarChar("@Id", id) };
var dt = await getSQLDatatable_async(sql, Conn, pl,
Security: _intranet.GetDbSecurity(), options: new FIS_SQLOptions());
return dt.Count > 0 ? dt.FirstRow.no("file", null) as byte[] : null;
}
}
+58
View File
@@ -0,0 +1,58 @@
namespace Fuchs.Services;
/// <summary>
/// Builds the per-blob metadata dictionary used when archiving documents (invoice/reminder PDFs,
/// and any future file-bearing type) to Azure Blob Storage — see <see cref="AzureBlobStorageService"/>
/// and <see cref="AzureBlobStorageSettings.MetadataFields"/>.
/// Only fields configured in <see cref="AzureBlobStorageSettings.MetadataFields"/> that are ALSO
/// present as a key on the source row are included: a field absent from a given document type's
/// row shape (e.g. reminders have no <c>file_guid</c>) is skipped entirely, while a field that is
/// present but holds a null/empty value is still emitted as an empty string.
/// </summary>
public static class DocumentMetadataBuilder
{
/// <summary>
/// Projects <paramref name="row"/> onto <paramref name="fields"/>. Column lookup is
/// case-insensitive because SQL-sourced rows (see <c>toObjectDictionary</c> /
/// <c>GenericObjectDictionary</c>) are frequently lower-cased.
/// </summary>
public static Dictionary<string, string> Build(IReadOnlyDictionary<string, object?> row, IEnumerable<string> fields)
{
var metadata = new Dictionary<string, string>();
foreach (string field in fields)
{
if (string.IsNullOrWhiteSpace(field)) continue;
if (!TryGetValue(row, field, out object? value)) continue;
metadata[field] = Stringify(value);
}
return metadata;
}
private static bool TryGetValue(IReadOnlyDictionary<string, object?> row, string field, out object? value)
{
if (row.TryGetValue(field, out value)) return true;
// Fall back to a case-insensitive match: rows built from SQL results are frequently
// lower-cased (see toObjectDictionary/GenericObjectDictionary) while MetadataFields
// entries are written using the column's natural casing (e.g. "InvoiceId").
foreach (var kvp in row)
{
if (string.Equals(kvp.Key, field, StringComparison.OrdinalIgnoreCase))
{
value = kvp.Value;
return true;
}
}
value = null;
return false;
}
private static string Stringify(object? value) =>
value switch
{
null => "",
DBNull => "",
DateTime dt => dt.ToString("O"),
_ => value.ToString() ?? ""
};
}
+16
View File
@@ -0,0 +1,16 @@
namespace Fuchs.Services;
/// <summary>
/// Email safety-net settings, bound from appsettings.json → "Fuchs:Email".
/// </summary>
public class FuchsEmailSettings
{
/// <summary>
/// Dev/test safety net: when set to a non-empty address, <see cref="ProcessWebComService"/>
/// discards the real recipient of every outbound email (to/cc/bcc) and redirects it to this
/// single address instead, so a locally-enabled mailer can never reach a real tenant-owner or
/// end-customer while testing. Configure this only in <c>appsettings.Development.json</c> —
/// it must stay empty/unset in Production.
/// </summary>
public string? OverrideRecipient { get; set; }
}
+5 -3
View File
@@ -1,4 +1,4 @@
using Fuchs.intranet;
using Fuchs.intranet;
using Fuchs.Observability;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -124,7 +124,8 @@ public class FuchsWidgetService : IWidgetService
{
case "sql_table":
{
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec,
options: new FIS_SQLOptions { CommandTimeout = 90 });
widgetData = new
{
name,
@@ -141,7 +142,8 @@ public class FuchsWidgetService : IWidgetService
case "sql_indicator":
{
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec);
var dt = await getSQLDatatable_async(sql, Conn, Params(userAccountId), Security: dbSec,
options: new FIS_SQLOptions { CommandTimeout = 90 });
var firstRow = dt.DataTable.Rows.Count > 0
? dt.DataTable.Rows[0].toObjectDictionary()
: new Dictionary<string, object?>();
+47
View File
@@ -0,0 +1,47 @@
namespace Fuchs.Services;
/// <summary>
/// Abstraction for archiving finalized documents (invoice/reminder PDFs, and any future
/// file-bearing type) to Azure Blob Storage, in addition to the existing SQL Server storage
/// (<c>fds__setInvoiceFile</c> / <c>fds__setReminderFile</c>).
/// </summary>
public interface IBlobStorageService
{
/// <summary>
/// Uploads a finalized invoice PDF to Azure Blob Storage. When <paramref name="sourceRow"/> is
/// supplied (typically <c>FdsInvoiceData.InvoiceRegistration</c>), blob metadata is projected from
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
/// or the upload failed — failures never break the primary DB-storage flow.
/// </summary>
Task<Uri?> UploadInvoicePdfAsync(string invoiceId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
/// <summary>
/// Uploads a finalized reminder PDF to Azure Blob Storage. When <paramref name="sourceRow"/> is
/// supplied (typically <c>FdsReminderData.ReminderRegistration</c>), blob metadata is projected from
/// it using <see cref="AzureBlobStorageSettings.MetadataFields"/> — see <see cref="DocumentMetadataBuilder"/>.
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured
/// or the upload failed — failures never break the primary DB-storage flow.
/// </summary>
Task<Uri?> UploadReminderPdfAsync(string reminderId, string fileName, byte[] content,
IReadOnlyDictionary<string, object?>? sourceRow = null, CancellationToken cancellationToken = default);
/// <summary>
/// Generic upload for any document category — used by the startup archive backfill so
/// invoice/reminder/future file types can all be archived through one entry point. The
/// caller supplies the target container name and a category label (used for logging/telemetry),
/// plus the source row driving metadata projection (see <see cref="DocumentMetadataBuilder"/>).
/// Returns the blob URI, or <c>null</c> when storage is disabled/unconfigured or the upload failed.
/// </summary>
Task<Uri?> UploadDocumentAsync(string category, string containerName, string documentId, string fileName,
byte[] content, IReadOnlyDictionary<string, object?>? sourceRow = null, 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.
/// Returns <c>false</c> (never throws) when storage is disabled/unconfigured or the check fails.
/// </summary>
Task<bool> ExistsAsync(string containerName, string documentId, string fileName,
CancellationToken cancellationToken = default);
}
+11 -3
View File
@@ -1,4 +1,4 @@
using System.Data;
using System.Data;
using System.Diagnostics;
using Fuchs.intranet;
using Fuchs.Observability;
@@ -22,12 +22,15 @@ public class InvoiceService : IInvoiceService
{
private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage;
private readonly ILogger<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, ILogger<InvoiceService> logger)
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
ILogger<InvoiceService> logger)
{
_intranet = intranet;
_pdf = pdf;
_blobStorage = blobStorage;
_logger = logger;
}
@@ -142,7 +145,12 @@ public class InvoiceService : IInvoiceService
bool r = await setSQLValue_async(
"EXECUTE [dbo].[fds__setInvoiceFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
return r ? ba : Array.Empty<byte>();
if (!r) return Array.Empty<byte>();
string fileName = invoice.InvoiceRegistration?.getString("DocumentName")
.ne($"Rechnung_{invoice.Id}.pdf") ?? $"Rechnung_{invoice.Id}.pdf";
await _blobStorage.UploadInvoicePdfAsync(invoice.Id, fileName, ba, invoice.InvoiceRegistration);
return ba;
}
public async Task<byte[]?> GetInvoiceFileAsync(FdsInvoiceData invoice, bool draft, fds.IFdsMfr mfr)
+19
View File
@@ -23,6 +23,7 @@ public class ProcessWebComService : IComService
private readonly ILogger<ProcessWebComService> _logger;
private readonly Fuchs_intranet _intranet;
private readonly ProcessWebComSettings _settings;
private readonly FuchsEmailSettings _emailSettings;
private readonly IHttpClientFactory _httpClientFactory;
private const string SignatureIntro =
@@ -34,11 +35,13 @@ public class ProcessWebComService : IComService
ILogger<ProcessWebComService> logger,
Fuchs_intranet intranet,
IOptions<ProcessWebComSettings> settings,
IOptions<FuchsEmailSettings> emailSettings,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_intranet = intranet;
_settings = settings.Value;
_emailSettings = emailSettings.Value;
_httpClientFactory = httpClientFactory;
}
@@ -47,6 +50,22 @@ public class ProcessWebComService : IComService
{
using var act = FuchsTelemetry.StartActivity("email.send");
act?.SetTag("fuchs.email.ref", reference);
string overrideRecipient = _emailSettings.OverrideRecipient ?? "";
if (!string.IsNullOrWhiteSpace(overrideRecipient))
{
// Dev/test safety net: discard the real recipient (to/cc/bcc) entirely and
// redirect every outbound email to a single controlled inbox, so a locally
// enabled mailer can never reach a real tenant-owner or end-customer.
_logger.LogWarning(
"SendEmailAsync: recipient override active for ref {Reference} redirecting from '{OriginalEmail}' to '{OverrideRecipient}'",
reference, email, overrideRecipient);
act?.SetTag("fuchs.email.overridden", true);
act?.SetTag("fuchs.email.original_recipient", email);
subject = $"[DEV \u2192 {email}] {subject}";
email = overrideRecipient;
}
if (!IsValidEmail(email))
{
_logger.LogWarning("SendEmailAsync: invalid email address '{Email}' for ref {Reference}", email, reference);
+11 -3
View File
@@ -1,4 +1,4 @@
using System.Data;
using System.Data;
using System.Diagnostics;
using Fuchs.intranet;
using Fuchs.Observability;
@@ -22,12 +22,15 @@ public class ReminderService : IReminderService
{
private readonly Fuchs_intranet _intranet;
private readonly IPdfService _pdf;
private readonly IBlobStorageService _blobStorage;
private readonly ILogger<ReminderService> _logger;
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, ILogger<ReminderService> logger)
public ReminderService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
ILogger<ReminderService> logger)
{
_intranet = intranet;
_pdf = pdf;
_blobStorage = blobStorage;
_logger = logger;
}
@@ -145,7 +148,12 @@ public class ReminderService : IReminderService
bool r = await setSQLValue_async(
"EXECUTE [dbo].[fds__setReminderFile] @Id, @file;",
Conn, pl, Security: dbSec, options: new FIS_SQLOptions());
return r ? ba : Array.Empty<byte>();
if (!r) return Array.Empty<byte>();
string fileName = reminder.ReminderRegistration?.getString("DocumentName")
.ne($"Zahlungserinnerung_{reminder.Id}.pdf") ?? $"Zahlungserinnerung_{reminder.Id}.pdf";
await _blobStorage.UploadReminderPdfAsync(reminder.Id, fileName, ba, reminder.ReminderRegistration);
return ba;
}
public async Task<byte[]> GetReminderFileAsync(FdsReminderData reminder, bool draft,