[CLAUDE] App+Api+FE-Admin: Form template builder (upload + edit + delete)
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m44s

Admin giờ có thể quản lý template HĐ hoàn toàn qua UI — không cần dev
đụng vào file system hay seed data.

BE (FormFeatures.cs + FormsController.cs):
- UploadContractTemplateCommand (multipart): validate FormCode
  (regex [A-Za-z0-9._-]+, unique), file <= 10MB, ext .docx/.xlsx,
  FieldSpec phải là JSON hợp lệ hoặc null. Ghi file vào
  wwwroot/templates/{formCode}_{guid:N}.{ext} để tránh collision
  + path traversal.
- UpdateContractTemplateCommand: sửa metadata + FieldSpec + IsActive
  (không đụng file — chỉ DB).
- DeleteContractTemplateCommand: soft delete qua IsActive=false
  (historical contracts ref template này vẫn resolve).
- Endpoints: POST /api/forms/templates (multipart),
  PUT /api/forms/templates/{id}, DELETE /api/forms/templates/{id}.
  RequestSizeLimit 12MB (validator caps 10MB).

FE (FormsPage.tsx admin):
- PageHeader action button "Upload template" mở dialog mới
- Row actions: Download (render existing), Pencil (edit), Trash (xóa
  confirm) thay vì chỉ có 1 nút Render — row hover reveals clearly
- Upload dialog: file picker với file: pseudo-element brand styled,
  FormCode (required, font-mono), Tên, Loại HĐ select, Mô tả,
  FieldSpec JSON textarea với placeholder example
- Edit dialog: same fields minus file (FormCode disabled, edit chỉ
  cập nhật metadata), có checkbox Kích hoạt
- Shared form submit handler — same dialog cho upload (__new) + edit

Foundation sẵn cho form builder thật (render UI từ FieldSpec JSON
đang là text field — iteration sau sẽ parse + render form dynamic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
pqhuy1987
2026-04-21 21:15:35 +07:00
parent c52186bed0
commit 166d26c1d8
3 changed files with 515 additions and 25 deletions

View File

@ -28,4 +28,50 @@ public class FormsController(IMediator mediator) : ControllerBase
var result = await mediator.Send(new RenderTemplateCommand(id, data), ct);
return File(result.Content, result.ContentType, result.FileName);
}
// ==================== Admin CRUD ====================
// Upload new template — multipart form with file + metadata fields
[HttpPost("templates")]
[RequestSizeLimit(12_000_000)] // ~12 MB (validator caps at 10 MB)
public async Task<ActionResult<ContractTemplateDto>> Upload(
IFormFile file,
[FromForm] string formCode,
[FromForm] string name,
[FromForm] ContractType? contractType = null,
[FromForm] string? description = null,
[FromForm] string? fieldSpec = null,
CancellationToken ct = default)
{
if (file is null || file.Length == 0)
return BadRequest(new { detail = "Chưa chọn file template." });
await using var stream = file.OpenReadStream();
var dto = await mediator.Send(new UploadContractTemplateCommand(
formCode, name, contractType, description, fieldSpec,
file.FileName, file.ContentType, file.Length, stream), ct);
return Ok(dto);
}
[HttpPut("templates/{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] UpdateContractTemplateBody body, CancellationToken ct)
{
await mediator.Send(new UpdateContractTemplateCommand(
id, body.Name, body.ContractType, body.Description, body.FieldSpec, body.IsActive), ct);
return NoContent();
}
[HttpDelete("templates/{id:guid}")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
await mediator.Send(new DeleteContractTemplateCommand(id), ct);
return NoContent();
}
}
public record UpdateContractTemplateBody(
string Name,
ContractType? ContractType,
string? Description,
string? FieldSpec,
bool IsActive);

View File

@ -94,3 +94,156 @@ public interface IWebHostEnvironmentLocator
string WebRootPath { get; }
string ContentRootPath { get; }
}
// ========== UPLOAD new template ==========
public record UploadContractTemplateCommand(
string FormCode,
string Name,
ContractType? ContractType,
string? Description,
string? FieldSpec,
string FileName,
string ContentType,
long FileSize,
Stream Content) : IRequest<ContractTemplateDto>;
public class UploadContractTemplateCommandValidator : AbstractValidator<UploadContractTemplateCommand>
{
private const long MaxBytes = 10L * 1024 * 1024;
private static readonly HashSet<string> AllowedFormats = new(StringComparer.OrdinalIgnoreCase) { ".docx", ".xlsx" };
public UploadContractTemplateCommandValidator()
{
RuleFor(x => x.FormCode).NotEmpty().MaximumLength(100)
.Matches("^[A-Za-z0-9._-]+$")
.WithMessage("FormCode chỉ dùng chữ, số, và các ký tự . _ -");
RuleFor(x => x.Name).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.FileSize).GreaterThan(0).LessThanOrEqualTo(MaxBytes)
.WithMessage("Template tối đa 10 MB.");
RuleFor(x => x.FileName).NotEmpty()
.Must(name => AllowedFormats.Contains(Path.GetExtension(name)))
.WithMessage("Chỉ chấp nhận file .docx hoặc .xlsx.");
RuleFor(x => x.FieldSpec).Must(BeValidJsonOrNull)
.WithMessage("FieldSpec phải là JSON hợp lệ (hoặc để trống).");
}
private static bool BeValidJsonOrNull(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return true;
try { System.Text.Json.JsonDocument.Parse(json); return true; }
catch { return false; }
}
}
public class UploadContractTemplateCommandHandler(
IApplicationDbContext db,
IWebHostEnvironmentLocator env) : IRequestHandler<UploadContractTemplateCommand, ContractTemplateDto>
{
public async Task<ContractTemplateDto> Handle(UploadContractTemplateCommand request, CancellationToken ct)
{
// Enforce unique FormCode — admin-friendly error via ConflictException
if (await db.ContractTemplates.AnyAsync(t => t.FormCode == request.FormCode, ct))
throw new ConflictException($"FormCode '{request.FormCode}' đã tồn tại.");
var ext = Path.GetExtension(request.FileName).TrimStart('.').ToLowerInvariant();
var id = Guid.NewGuid();
// Store using a deterministic name — FormCode avoids path-traversal
// (already regex-validated above) and disambiguates files.
var safeFile = $"{request.FormCode}_{id:N}.{ext}";
var templatesDir = Path.Combine(env.WebRootPath, "templates");
Directory.CreateDirectory(templatesDir);
var absPath = Path.Combine(templatesDir, safeFile);
await using (var fs = File.Create(absPath))
await request.Content.CopyToAsync(fs, ct);
var entity = new Domain.Forms.ContractTemplate
{
Id = id,
FormCode = request.FormCode,
Name = request.Name,
ContractType = request.ContractType,
FileName = request.FileName,
StoragePath = $"templates/{safeFile}",
Format = ext,
FieldSpec = request.FieldSpec,
Description = request.Description,
IsActive = true,
};
db.ContractTemplates.Add(entity);
await db.SaveChangesAsync(ct);
return new ContractTemplateDto(
entity.Id, entity.FormCode, entity.Name, entity.ContractType,
entity.FileName, entity.Format, entity.FieldSpec, entity.Description, entity.IsActive);
}
}
// ========== UPDATE metadata + FieldSpec ==========
public record UpdateContractTemplateCommand(
Guid Id,
string Name,
ContractType? ContractType,
string? Description,
string? FieldSpec,
bool IsActive) : IRequest;
public class UpdateContractTemplateCommandValidator : AbstractValidator<UpdateContractTemplateCommand>
{
public UpdateContractTemplateCommandValidator()
{
RuleFor(x => x.Id).NotEmpty();
RuleFor(x => x.Name).NotEmpty().MaximumLength(300);
RuleFor(x => x.Description).MaximumLength(1000);
RuleFor(x => x.FieldSpec).Must(BeValidJsonOrNull)
.WithMessage("FieldSpec phải là JSON hợp lệ (hoặc để trống).");
}
private static bool BeValidJsonOrNull(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return true;
try { System.Text.Json.JsonDocument.Parse(json); return true; }
catch { return false; }
}
}
public class UpdateContractTemplateCommandHandler(IApplicationDbContext db)
: IRequestHandler<UpdateContractTemplateCommand>
{
public async Task Handle(UpdateContractTemplateCommand request, CancellationToken ct)
{
var entity = await db.ContractTemplates.FirstOrDefaultAsync(t => t.Id == request.Id, ct)
?? throw new NotFoundException("ContractTemplate", request.Id);
entity.Name = request.Name;
entity.ContractType = request.ContractType;
entity.Description = request.Description;
entity.FieldSpec = request.FieldSpec;
entity.IsActive = request.IsActive;
await db.SaveChangesAsync(ct);
}
}
// ========== DELETE (soft via IsActive=false) ==========
public record DeleteContractTemplateCommand(Guid Id) : IRequest;
public class DeleteContractTemplateCommandHandler(IApplicationDbContext db)
: IRequestHandler<DeleteContractTemplateCommand>
{
public async Task Handle(DeleteContractTemplateCommand request, CancellationToken ct)
{
var entity = await db.ContractTemplates.FirstOrDefaultAsync(t => t.Id == request.Id, ct)
?? throw new NotFoundException("ContractTemplate", request.Id);
// Soft delete — AuditableEntity query filter already handles IsDeleted.
// But simpler MVP: just flip IsActive so historical contracts referencing
// this template can still resolve it.
entity.IsActive = false;
await db.SaveChangesAsync(ct);
}
}