Backend Security & Data Integrity: - Block negative debit/credit amounts that bypass balance validation - Require document date at posting (was optional, bypassing fiscal year checks) - Fix event sourcing anti-pattern: timestamps now stored in events, not UtcNow in Apply - Add [Authorize] to BankingController OAuth callback - Add company access check on attachment downloads - Validate CVR in CompanyAggregate.Create and CompanyAggregate.Update - Require company CVR for invoice creation (Momsloven §52) - Delete leftover WeatherForecastController - Fix duplicate migration number 007 (renamed to 007b) - Remove dead code in VatCalculationService (identical if/else branches) Accounting Compliance: - Add missing VAT accounts to StandardDanishAccounts (5610, 5611, 5620) - Populate SAF-T TaxInformation on transaction lines (was always null) - Add AuditFileCountry and TaxRegistrationNumber to SAF-T header Critical Frontend Bugs: - Fix Dashboard <a href> causing full page reloads (now uses React Router Link) - Wire Kassekladde filters to actual data (account, status, date range) - Pre-populate form when editing existing Kassekladde drafts - Add detail drawer for "Vis detaljer" action (was just a toast) - Toggle advanced filters with "Flere filtre" button - CloseFiscalYearWizard now actually posts closing entries via mutations - "Create next year" checkbox now creates the next fiscal year Danish Character Encoding (~50 fixes): - Fix ø/æ/å across Momsindberetning, DocumentUploadModal, Bankafstemning, Kontooversigt, CloseFiscalYearWizard, vatCodes, periodStore, periods, accounting, types/periods Dead Buttons & UX: - Disable Momsindberetning PDF/Export buttons with tooltips - FiscalYearSelector "Administrer" now navigates to Settings - Settings bank tab now uses real BankConnectionsTab component - Bankafstemning save button disabled with development tooltip - Replace hardcoded account options with real API data (Bankafstemning, Fakturaer) - Header help button shows info message, notification bell shows popover Consistency & Quality: - Remove 7 console.log statements from production code - Adopt PageHeader on 6 remaining pages (Kreditnotaer, Settings, Admin, etc.) - Standardize loading states to Skeleton pattern (5 pages) - Replace deprecated bodyStyle prop on Ant Design Cards - Standardize date format to DD-MM-YYYY - Fix sidebar width mismatch in designTokens - Fix Kontooversigt breadcrumb pointing to non-existent route Accessibility: - Add aria-label to sidebar navigation - Add +/- prefix to AmountText for color-blind users - Fix CompanySwitcher permanent skeleton when no companies Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
283 lines
9.6 KiB
C#
283 lines
9.6 KiB
C#
using System.Security.Claims;
|
|
using Books.Api.Authorization;
|
|
using Books.Api.Commands.Attachments;
|
|
using Books.Api.Domain;
|
|
using Books.Api.Domain.Attachments;
|
|
using Books.Api.EventFlow.ReadModels;
|
|
using Books.Api.EventFlow.Repositories;
|
|
using Books.Api.Infrastructure.FileStorage;
|
|
using EventFlow;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
|
|
namespace Books.Api.Controllers;
|
|
|
|
/// <summary>
|
|
/// REST API for attachment (bilag) file operations.
|
|
/// GraphQL doesn't handle file uploads well, so we use REST for this.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("api/attachments")]
|
|
[Authorize]
|
|
public class AttachmentController(
|
|
ICommandBus commandBus,
|
|
IAttachmentRepository attachmentRepository,
|
|
IFileStorageService fileStorage,
|
|
ICompanyAccessService companyAccess,
|
|
ILogger<AttachmentController> logger) : ControllerBase
|
|
{
|
|
/// <summary>
|
|
/// Upload one or more attachments for a company.
|
|
/// </summary>
|
|
/// <param name="companyId">Company ID</param>
|
|
/// <param name="draftId">Optional draft ID to link attachments to</param>
|
|
/// <param name="files">Files to upload</param>
|
|
[HttpPost("upload")]
|
|
[RequestSizeLimit(50 * 1024 * 1024)] // 50MB max total
|
|
public async Task<IActionResult> Upload(
|
|
[FromQuery] string companyId,
|
|
[FromQuery] string? draftId,
|
|
[FromForm] List<IFormFile> files,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Validate company access
|
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
return Unauthorized(new { error = "NOT_AUTHENTICATED", message = "You must be authenticated to upload attachments" });
|
|
}
|
|
|
|
// Check if user can write (Accountant or Owner role)
|
|
var canWrite = await companyAccess.CanWriteAsync(companyId, cancellationToken);
|
|
if (!canWrite)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
if (files.Count == 0)
|
|
{
|
|
return BadRequest(new { error = "NO_FILES", message = "No files provided" });
|
|
}
|
|
|
|
var results = new List<object>();
|
|
|
|
foreach (var file in files)
|
|
{
|
|
try
|
|
{
|
|
// Validate file size (10MB per file)
|
|
if (file.Length > 10 * 1024 * 1024)
|
|
{
|
|
results.Add(new
|
|
{
|
|
success = false,
|
|
fileName = file.FileName,
|
|
error = "FILE_TOO_LARGE",
|
|
message = $"File '{file.FileName}' exceeds 10MB limit"
|
|
});
|
|
continue;
|
|
}
|
|
|
|
// Store file
|
|
await using var stream = file.OpenReadStream();
|
|
var storageResult = await fileStorage.StoreAsync(
|
|
companyId,
|
|
file.FileName,
|
|
file.ContentType,
|
|
stream,
|
|
cancellationToken);
|
|
|
|
// Create attachment aggregate
|
|
var attachmentId = AttachmentId.New;
|
|
var command = new UploadAttachmentCommand(
|
|
attachmentId,
|
|
companyId,
|
|
storageResult.StoredFileName,
|
|
file.FileName,
|
|
file.ContentType,
|
|
storageResult.FileSize,
|
|
storageResult.StoragePath,
|
|
userId,
|
|
draftId);
|
|
|
|
await commandBus.PublishAsync(command, cancellationToken);
|
|
|
|
// Wait briefly for read model to be updated (eventual consistency)
|
|
await Task.Delay(100, cancellationToken);
|
|
|
|
var attachment = await attachmentRepository.GetByIdAsync(attachmentId.Value, cancellationToken);
|
|
|
|
results.Add(new
|
|
{
|
|
success = true,
|
|
id = attachmentId.Value,
|
|
fileName = file.FileName,
|
|
fileType = file.ContentType,
|
|
fileSize = storageResult.FileSize,
|
|
uploadedAt = attachment?.UploadedAt ?? DateTimeOffset.UtcNow,
|
|
url = fileStorage.GetDownloadUrl(storageResult.StoragePath)
|
|
});
|
|
}
|
|
catch (DomainException ex)
|
|
{
|
|
logger.LogWarning(ex, "Failed to upload file {FileName}", file.FileName);
|
|
results.Add(new
|
|
{
|
|
success = false,
|
|
fileName = file.FileName,
|
|
error = ex.Code,
|
|
message = ex.MessageDanish
|
|
});
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "Unexpected error uploading file {FileName}", file.FileName);
|
|
results.Add(new
|
|
{
|
|
success = false,
|
|
fileName = file.FileName,
|
|
error = "UPLOAD_FAILED",
|
|
message = "An unexpected error occurred"
|
|
});
|
|
}
|
|
}
|
|
|
|
return Ok(new { attachments = results });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get attachments for a draft.
|
|
/// </summary>
|
|
[HttpGet("draft/{draftId}")]
|
|
public async Task<IActionResult> GetByDraft(string draftId, CancellationToken cancellationToken)
|
|
{
|
|
var attachments = await attachmentRepository.GetByDraftIdAsync(draftId, cancellationToken);
|
|
|
|
if (attachments.Count == 0)
|
|
{
|
|
return Ok(new { attachments = Array.Empty<object>() });
|
|
}
|
|
|
|
// Validate company access (use first attachment's company)
|
|
var access = await companyAccess.GetAccessAsync(attachments[0].CompanyId, cancellationToken);
|
|
if (access == null)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
var result = attachments.Select(a => new
|
|
{
|
|
id = a.Id,
|
|
fileName = a.OriginalFileName,
|
|
fileType = a.ContentType,
|
|
fileSize = a.FileSize,
|
|
uploadedAt = a.UploadedAt.ToString("O"),
|
|
uploadedBy = a.UploadedBy,
|
|
url = fileStorage.GetDownloadUrl(a.StoragePath)
|
|
});
|
|
|
|
return Ok(new { attachments = result });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Download an attachment by storage path.
|
|
/// </summary>
|
|
[HttpGet("{*storagePath}")]
|
|
public async Task<IActionResult> Download(string storagePath, CancellationToken cancellationToken)
|
|
{
|
|
// Validate path to prevent directory traversal attacks
|
|
if (string.IsNullOrWhiteSpace(storagePath) ||
|
|
storagePath.Contains("..") ||
|
|
storagePath.Contains("~") ||
|
|
Path.IsPathRooted(storagePath) ||
|
|
storagePath.StartsWith("/") ||
|
|
storagePath.StartsWith("\\"))
|
|
{
|
|
logger.LogWarning("Attempted path traversal attack with path: {StoragePath}", storagePath);
|
|
return BadRequest(new { error = "INVALID_PATH", message = "Invalid storage path" });
|
|
}
|
|
|
|
// Look up the attachment to verify company access
|
|
var attachment = await attachmentRepository.GetByStoragePathAsync(storagePath, cancellationToken);
|
|
if (attachment == null)
|
|
{
|
|
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
|
|
}
|
|
|
|
// Verify the user has access to the company that owns this attachment
|
|
var access = await companyAccess.GetAccessAsync(attachment.CompanyId, cancellationToken);
|
|
if (access == null)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
var file = await fileStorage.GetAsync(storagePath, cancellationToken);
|
|
|
|
if (file == null)
|
|
{
|
|
return NotFound(new { error = "FILE_NOT_FOUND", message = "Attachment not found" });
|
|
}
|
|
|
|
return File(file.Content, file.ContentType, file.FileName);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete an attachment.
|
|
/// Note: Per Bogføringsloven § 6, attachments linked to transactions
|
|
/// cannot be deleted within the 5-year retention period.
|
|
/// </summary>
|
|
[HttpDelete("{id}")]
|
|
public async Task<IActionResult> Delete(
|
|
string id,
|
|
[FromQuery] string reason,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var attachment = await attachmentRepository.GetByIdAsync(id, cancellationToken);
|
|
|
|
if (attachment == null)
|
|
{
|
|
return NotFound(new { error = "ATTACHMENT_NOT_FOUND", message = "Attachment not found" });
|
|
}
|
|
|
|
// Validate company access - need write permission to delete
|
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
if (string.IsNullOrEmpty(userId))
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
var canWrite = await companyAccess.CanWriteAsync(attachment.CompanyId, cancellationToken);
|
|
if (!canWrite)
|
|
{
|
|
return Forbid();
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(reason))
|
|
{
|
|
return BadRequest(new
|
|
{
|
|
error = "REASON_REQUIRED",
|
|
message = "A reason for deletion is required (Bogføringsloven § 6)"
|
|
});
|
|
}
|
|
|
|
try
|
|
{
|
|
var command = new DeleteAttachmentCommand(
|
|
new AttachmentId(id),
|
|
userId,
|
|
reason);
|
|
|
|
await commandBus.PublishAsync(command, cancellationToken);
|
|
|
|
// Also delete the physical file
|
|
await fileStorage.DeleteAsync(attachment.StoragePath, cancellationToken);
|
|
|
|
return Ok(new { success = true, message = "Attachment deleted" });
|
|
}
|
|
catch (DomainException ex)
|
|
{
|
|
return BadRequest(new { error = ex.Code, message = ex.MessageDanish });
|
|
}
|
|
}
|
|
}
|