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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() ?? ""
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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?>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user