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
+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),