[CLAUDE] App+Infra+Api+FE: Attachment upload E2E
Some checks failed
Deploy SOLUTION_ERP / build-deploy (push) Failing after 1m40s
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:
@ -15,6 +15,7 @@ using SolutionErp.Infrastructure.Persistence;
|
||||
using SolutionErp.Infrastructure.Persistence.Interceptors;
|
||||
using SolutionErp.Infrastructure.Reports;
|
||||
using SolutionErp.Infrastructure.Services;
|
||||
using SolutionErp.Infrastructure.Storage;
|
||||
|
||||
namespace SolutionErp.Infrastructure;
|
||||
|
||||
@ -32,6 +33,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IContractWorkflowService, ContractWorkflowService>();
|
||||
services.AddScoped<IContractExcelExporter, ContractExcelExporter>();
|
||||
services.AddScoped<INotificationService, NotificationService>();
|
||||
services.AddSingleton<IFileStorage, LocalFileStorage>();
|
||||
|
||||
// Phase 3 iteration 2 — SLA auto-approve background service
|
||||
services.AddHostedService<SlaExpiryJob>();
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using SolutionErp.Application.Common.Interfaces;
|
||||
|
||||
namespace SolutionErp.Infrastructure.Storage;
|
||||
|
||||
public class LocalFileStorage : IFileStorage
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public LocalFileStorage(IConfiguration config)
|
||||
{
|
||||
var root = config["Uploads:RootPath"] ?? "uploads";
|
||||
// Make absolute (relative paths resolve against ContentRoot)
|
||||
_root = Path.GetFullPath(root);
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
private string Full(string rel)
|
||||
{
|
||||
var full = Path.GetFullPath(Path.Combine(_root, rel));
|
||||
// Guard against path traversal — resolved full path must stay inside root
|
||||
if (!full.StartsWith(_root, StringComparison.OrdinalIgnoreCase))
|
||||
throw new UnauthorizedAccessException("Path traversal blocked.");
|
||||
return full;
|
||||
}
|
||||
|
||||
public async Task<string> SaveAsync(string relativePath, Stream content, CancellationToken ct = default)
|
||||
{
|
||||
var full = Full(relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(full)!);
|
||||
await using var fs = File.Create(full);
|
||||
await content.CopyToAsync(fs, ct);
|
||||
return relativePath.Replace('\\', '/');
|
||||
}
|
||||
|
||||
public Task<Stream> OpenReadAsync(string relativePath, CancellationToken ct = default)
|
||||
{
|
||||
var full = Full(relativePath);
|
||||
if (!File.Exists(full)) throw new FileNotFoundException(relativePath);
|
||||
return Task.FromResult<Stream>(File.OpenRead(full));
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string relativePath, CancellationToken ct = default)
|
||||
{
|
||||
var full = Full(relativePath);
|
||||
if (File.Exists(full)) File.Delete(full);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public bool Exists(string relativePath) => File.Exists(Full(relativePath));
|
||||
}
|
||||
Reference in New Issue
Block a user