[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:
@ -66,6 +66,41 @@ public class ContractsController(IMediator mediator) : ControllerBase
|
||||
await mediator.Send(new DeleteContractCommand(id), ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
// ==================== Attachments ====================
|
||||
|
||||
// Multipart upload: file + purpose + optional note
|
||||
[HttpPost("{id:guid}/attachments")]
|
||||
[RequestSizeLimit(25_000_000)] // ~24 MB ceiling (validator enforces 20 MB)
|
||||
public async Task<ActionResult<ContractAttachmentDto>> UploadAttachment(
|
||||
Guid id,
|
||||
IFormFile file,
|
||||
[FromForm] AttachmentPurpose purpose = AttachmentPurpose.Other,
|
||||
[FromForm] string? note = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { detail = "Chưa chọn file." });
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var dto = await mediator.Send(new UploadContractAttachmentCommand(
|
||||
id, file.FileName, file.ContentType, file.Length, stream, purpose, note), ct);
|
||||
return Ok(dto);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}/attachments/{attId:guid}/download")]
|
||||
public async Task<IActionResult> DownloadAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
{
|
||||
var file = await mediator.Send(new DownloadContractAttachmentQuery(id, attId), ct);
|
||||
return File(file.Content, file.ContentType, file.FileName);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}/attachments/{attId:guid}")]
|
||||
public async Task<IActionResult> DeleteAttachment(Guid id, Guid attId, CancellationToken ct)
|
||||
{
|
||||
await mediator.Send(new DeleteContractAttachmentCommand(id, attId), ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record TransitionContractBody(ContractPhase TargetPhase, ApprovalDecision Decision, string? Comment);
|
||||
|
||||
@ -50,5 +50,8 @@
|
||||
"RateLimit": {
|
||||
"AuthLoginPerMinute": 5,
|
||||
"GlobalPerMinute": 300
|
||||
},
|
||||
"Uploads": {
|
||||
"RootPath": "C:\\inetpub\\solution-erp\\uploads"
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,5 +26,8 @@
|
||||
"RateLimit": {
|
||||
"AuthLoginPerMinute": 5,
|
||||
"GlobalPerMinute": 300
|
||||
},
|
||||
"Uploads": {
|
||||
"RootPath": "uploads"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user