[CLAUDE] App+Infra+Api+FE: Attachment upload E2E
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m40s

Foundation file-storage:
- IFileStorage interface (Application) — SaveAsync/OpenReadAsync/
  DeleteAsync/Exists. Future swap cho S3/Azure Blob không đổi caller.
- LocalFileStorage (Infrastructure) — resolve Uploads:RootPath từ
  config, path-traversal guard (resolved full path phải stay in root),
  tự tạo directory khi save.
- DI: singleton (stateless).
- Config: dev "uploads", prod "C:\inetpub\solution-erp\uploads".

CQRS:
- UploadContractAttachmentCommand: validate size <=20MB + MIME whitelist
  (pdf, doc/docx, xls/xlsx, png/jpg/jpeg/webp). Sanitize filename
  (strip path components + invalid FS chars + leading dots). Storage
  path: contracts/{contractId}/{attId}_{safeFileName}.
- DownloadContractAttachmentQuery: trả Stream + FileName + ContentType.
- DeleteContractAttachmentCommand: best-effort file delete sau DB remove
  (orphan cleanup job có thể sweep sau).

Api:
- POST /api/contracts/{id}/attachments — multipart/form-data, field
  'file' + form fields 'purpose' + 'note'. RequestSizeLimit 25MB
  (validator enforces 20MB).
- GET /api/contracts/{id}/attachments/{attId}/download — File() stream.
- DELETE /api/contracts/{id}/attachments/{attId}.

FE ContractAttachmentsSection (both apps, identical):
- Drag-drop zone với dragging highlight (brand-500 border + brand-50 bg)
- Purpose selector (DraftExport / ScannedSigned / SealedCopy / Other)
- List có icon per MIME (FileText/Image/File), filename, metadata
  (purpose · size · createdAt), download button (fetch blob + trigger
  browser save với auth header), delete button (confirm dialog)
- Empty state hint về use-case ("bản scan HĐ đã ký ở phase In ký…")

Integrated vào cả 2 ContractDetailPage — ngay dưới phần comments,
trước sidebar lịch sử duyệt.

Unblock E2E workflow: users giờ có thể upload bản scan ký (DangInKy),
scan đóng dấu (DangDongDau) — phase transitions có bằng chứng thật.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 20:37:35 +07:00
parent 346bd5d644
commit c8d0070770
11 changed files with 713 additions and 0 deletions

View File

@ -0,0 +1,147 @@
using FluentValidation;
using MediatR;
using Microsoft.EntityFrameworkCore;
using SolutionErp.Application.Common.Exceptions;
using SolutionErp.Application.Common.Interfaces;
using SolutionErp.Application.Contracts.Dtos;
using SolutionErp.Domain.Contracts;
namespace SolutionErp.Application.Contracts;
// ========== UPLOAD ==========
// File payload is decoupled from the command so MediatR-style CQRS works with
// multipart/form-data: the controller reads IFormFile, hands the Application
// layer a bare Stream + metadata. Keeps Application free of ASP.NET types.
public record UploadContractAttachmentCommand(
Guid ContractId,
string FileName,
string ContentType,
long FileSize,
Stream Content,
AttachmentPurpose Purpose,
string? Note) : IRequest<ContractAttachmentDto>;
public class UploadContractAttachmentCommandValidator : AbstractValidator<UploadContractAttachmentCommand>
{
// 20 MB default — configurable later via Uploads:MaxFileSize if needed
private const long MaxBytes = 20L * 1024 * 1024;
// MIME whitelist — contract scans (pdf/image) + editable sources (word/excel)
private static readonly HashSet<string> AllowedContentTypes = new(StringComparer.OrdinalIgnoreCase)
{
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
"application/msword", // .doc
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
"application/vnd.ms-excel", // .xls
"image/png",
"image/jpeg",
"image/webp",
};
public UploadContractAttachmentCommandValidator()
{
RuleFor(x => x.ContractId).NotEmpty();
RuleFor(x => x.FileName).NotEmpty().MaximumLength(255);
RuleFor(x => x.FileSize).GreaterThan(0).LessThanOrEqualTo(MaxBytes)
.WithMessage("File vượt quá 20 MB.");
RuleFor(x => x.ContentType).Must(c => AllowedContentTypes.Contains(c))
.WithMessage("Định dạng file không được hỗ trợ. Cho phép: pdf, doc(x), xls(x), png, jpg, webp.");
RuleFor(x => x.Purpose).IsInEnum();
RuleFor(x => x.Note).MaximumLength(500);
}
}
public class UploadContractAttachmentCommandHandler(
IApplicationDbContext db,
IFileStorage storage) : IRequestHandler<UploadContractAttachmentCommand, ContractAttachmentDto>
{
public async Task<ContractAttachmentDto> Handle(UploadContractAttachmentCommand request, CancellationToken ct)
{
var contract = await db.Contracts.FirstOrDefaultAsync(c => c.Id == request.ContractId, ct)
?? throw new NotFoundException("Contract", request.ContractId);
var attId = Guid.NewGuid();
var safeName = SanitizeFileName(request.FileName);
// Store under contracts/{contractId}/{attId}_{safeName} to keep originals separate
var relativePath = $"contracts/{contract.Id}/{attId}_{safeName}";
await storage.SaveAsync(relativePath, request.Content, ct);
var entity = new ContractAttachment
{
Id = attId,
ContractId = contract.Id,
FileName = request.FileName, // keep original for display
StoragePath = relativePath,
FileSize = request.FileSize,
ContentType = request.ContentType,
Purpose = request.Purpose,
Note = request.Note,
};
db.ContractAttachments.Add(entity);
await db.SaveChangesAsync(ct);
return new ContractAttachmentDto(
entity.Id, entity.FileName, entity.StoragePath, entity.FileSize,
entity.ContentType, entity.Purpose, entity.Note, DateTime.UtcNow);
}
private static string SanitizeFileName(string name)
{
// Strip path components + replace chars that are invalid on filesystems
var baseName = Path.GetFileName(name);
foreach (var c in Path.GetInvalidFileNameChars())
baseName = baseName.Replace(c, '_');
// Also strip leading dots (hidden files) and collapse whitespace
baseName = baseName.TrimStart('.');
if (string.IsNullOrWhiteSpace(baseName)) baseName = "file";
return baseName.Length > 200 ? baseName[^200..] : baseName;
}
}
// ========== DOWNLOAD ==========
public record DownloadContractAttachmentQuery(Guid ContractId, Guid AttachmentId) : IRequest<ContractAttachmentFile>;
public record ContractAttachmentFile(Stream Content, string FileName, string ContentType);
public class DownloadContractAttachmentQueryHandler(
IApplicationDbContext db,
IFileStorage storage) : IRequestHandler<DownloadContractAttachmentQuery, ContractAttachmentFile>
{
public async Task<ContractAttachmentFile> Handle(DownloadContractAttachmentQuery request, CancellationToken ct)
{
var att = await db.ContractAttachments.AsNoTracking()
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.ContractId == request.ContractId, ct)
?? throw new NotFoundException("Attachment", request.AttachmentId);
var stream = await storage.OpenReadAsync(att.StoragePath, ct);
return new ContractAttachmentFile(stream, att.FileName, att.ContentType);
}
}
// ========== DELETE ==========
public record DeleteContractAttachmentCommand(Guid ContractId, Guid AttachmentId) : IRequest;
public class DeleteContractAttachmentCommandHandler(
IApplicationDbContext db,
IFileStorage storage) : IRequestHandler<DeleteContractAttachmentCommand>
{
public async Task Handle(DeleteContractAttachmentCommand request, CancellationToken ct)
{
var att = await db.ContractAttachments
.FirstOrDefaultAsync(a => a.Id == request.AttachmentId && a.ContractId == request.ContractId, ct)
?? throw new NotFoundException("Attachment", request.AttachmentId);
db.ContractAttachments.Remove(att);
await db.SaveChangesAsync(ct);
// Best-effort file delete — if it fails, DB row is already gone (orphan
// cleanup job can sweep storage later). Don't block API response.
try { await storage.DeleteAsync(att.StoragePath, ct); }
catch { /* ignore */ }
}
}