using System.Globalization; using System.Text.Json; using Amazon.Runtime; using DocumentFormat.OpenXml.Packaging; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Npgsql; using WordVersioning.Api.Data; using WordVersioning.Api.Models; using WordVersioning.Api.Services; namespace WordVersioning.Api.Controllers; /// FR-5 (upload version), FR-6 (history), FR-7 (download), /// FR-10 (generate จาก template), FR-11 (field sync ตอน upload) [ApiController] [Authorize] // FR-8: ทุก endpoint ต้องแนบ token [Route("api/documents")] public class DocumentsController( AppDbContext db, IObjectStorageService storage, IHashService hasher, IDocxGenerationService docxGeneration, IFieldSyncService fieldSync, ICurrentUser currentUser, IOptions minioOptions, ILogger logger) : ControllerBase { public const string DocxContentType = DocxUploadValidation.DocxContentType; private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web); private const long MaxUploadBytes = 100L * 1024 * 1024; // 100MB ตาม spec private const int MaxVersionInsertRetries = 5; /// FR-5 — อัปโหลด version ใหม่ (auto-create Document ถ้ายังไม่มี) /// + FR-11: แนบ `fields` (map tag→text) → sync latest values + snapshot ต่อ version [HttpPost("{documentId:guid}/versions")] [RequestSizeLimit(MaxUploadBytes)] [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)] [ProducesResponseType(typeof(UploadVersionResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] [ProducesResponseType(StatusCodes.Status502BadGateway)] public async Task UploadVersion(Guid documentId, [FromForm] UploadVersionRequest request, CancellationToken ct) { if (request.File is null || request.File.Length == 0) return BadRequest("ต้องแนบไฟล์ `file` (docx)"); if (!Guid.TryParse(request.ClientVersionId, out _)) return BadRequest("`clientVersionId` ต้องเป็น GUID"); if (request.File.Length > MaxUploadBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB"); if (!DocxUploadValidation.IsValidDocx(request.File)) return BadRequest("รองรับเฉพาะไฟล์ .docx (Word document)"); // parse fields map (FR-11) — optional, ผิด format คือ 400 if (!FieldMapJson.TryParse(request.Fields, out var fieldMap, out var fieldsError)) return BadRequest(fieldsError); var now = DateTimeOffset.UtcNow; var fileName = request.File.FileName; // hash ระหว่าง stream ผ่าน (pass แรก) แล้ว rewind ส่ง MinIO (pass สอง) await using var fileStream = request.File.OpenReadStream(); Stream uploadStream = fileStream; if (!fileStream.CanSeek) { // IFormFile ปกติ seek ได้ — กันเหลือ: buffer ก่อน var buffered = new MemoryStream(); await fileStream.CopyToAsync(buffered, ct); uploadStream = buffered; } var contentHash = await hasher.ComputeSha256HexAsync(uploadStream, ct); uploadStream.Seek(0, SeekOrigin.Begin); // สร้าง Document ถ้ายังไม่มี (FR-5) var doc = await db.Documents.FirstOrDefaultAsync(d => d.Id == documentId, ct); var createdDoc = false; if (doc is null) { doc = new Document { Id = documentId, FileName = fileName, AddInSignature = string.Empty, // ฝั่ง add-in stamp ในไฟล์เอง CreatedAt = now, }; db.Documents.Add(doc); createdDoc = true; } // FR-11 — stage การ sync field values ก่อน insert version (ค่าเดิมเก็บไว้เผื่อต้องถอนกลับ) var versionId = Guid.NewGuid(); Dictionary? fieldValuesBefore = null; string? fieldsSnapshotJson; if (doc.TemplateId is not null) { fieldValuesBefore = await db.DocumentFieldValues.AsNoTracking() .Where(v => v.DocumentId == documentId) .ToDictionaryAsync(v => v.FieldKey, ct); var sync = await fieldSync.StageFieldSyncAsync(doc, versionId, fieldMap, now, ct); fieldsSnapshotJson = sync.SnapshotJson; } else { // เอกสารไม่ผูก template (track เองจาก add-in) — ไม่มีนิยามให้ sync เก็บ map ดิบใน snapshot fieldsSnapshotJson = fieldMap.Count == 0 ? null : JsonSerializer.Serialize(fieldMap, SnapshotJsonOptions); } // จอง versionNumber = max+1 — ถ้าชน unique index แปลว่ามี request คู่แข่ง ให้คำนวณใหม่ DocumentVersion version = null!; for (var attempt = 1; ; attempt++) { var nextNumber = await db.DocumentVersions .Where(v => v.DocumentId == documentId) .Select(v => (int?)v.VersionNumber) .MaxAsync(ct) ?? 0; nextNumber++; version = new DocumentVersion { Id = versionId, // ใช้ id เดิมทุก retry — field sync ที่ stage ไว้อ้างอิง id นี้ DocumentId = documentId, VersionNumber = nextNumber, ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx", ContentHash = contentHash, CreatedByUserId = currentUser.UserId, // จาก claim `sub` เท่านั้น CreatedAt = now, FieldsSnapshotJson = fieldsSnapshotJson, }; db.DocumentVersions.Add(version); try { await db.SaveChangesAsync(ct); break; } catch (DbUpdateException ex) when (IsUniqueViolation(ex) && attempt < MaxVersionInsertRetries) { logger.LogWarning( "Version number conflict documentId={DocumentId} attempt={Attempt} userId={UserId} — retry", documentId, attempt, currentUser.UserId); db.Entry(version).State = EntityState.Detached; if (createdDoc) { // document อาจถูกสร้างโดย request คู่แข่งไปแล้ว — ถอดออกจาก tracker แล้วอ่านใหม่ var existing = await db.Documents.AsNoTracking() .FirstOrDefaultAsync(d => d.Id == documentId, ct); if (existing is not null) { db.Entry(doc).State = EntityState.Detached; doc = existing; createdDoc = false; } } } } // upload ลง MinIO หลังจองเลข version สำเร็จ (key มี versionNumber อยู่ข้างใน) try { await storage.PutAsync(minioOptions.Value.VersionsBucket, version.ObjectKey, uploadStream, DocxContentType, ct); } catch (AmazonServiceException ex) { logger.LogError(ex, "MinIO upload failed documentId={DocumentId} versionId={VersionId} userId={UserId}", documentId, version.Id, currentUser.UserId); // ถอน record ที่เพิ่ง insert — ไม่ทิ้ง row ที่ชี้ไฟล์ที่ไม่มีจริง db.DocumentVersions.Remove(version); if (createdDoc) db.Documents.Remove(doc); RevertStagedFieldChanges(db, fieldValuesBefore); await db.SaveChangesAsync(CancellationToken.None); return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); } logger.LogInformation( "Version uploaded documentId={DocumentId} versionId={VersionId} versionNumber={VersionNumber} userId={UserId} bytes={Size} hash={Hash}", documentId, version.Id, version.VersionNumber, currentUser.UserId, request.File.Length, contentHash); return CreatedAtAction(nameof(GetVersions), new { documentId }, new UploadVersionResponse(version.Id, version.VersionNumber)); } /// FR-10 — สร้างเอกสารจาก template: generate docx + stamp metadata + upload version 1 /// + field values ทั้งหมดใน flow เดียว — เปิดใน Word แล้ว add-in ตรวจพบ tracked document ทันที (AC-12) [HttpPost("generate")] [ProducesResponseType(typeof(GenerateDocumentResponse), StatusCodes.Status201Created)] [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status502BadGateway)] public async Task Generate([FromBody] GenerateDocumentRequest request, CancellationToken ct) { var template = await db.Templates.AsNoTracking() .FirstOrDefaultAsync(t => t.Id == request.TemplateId, ct); if (template is null) return NotFound($"ไม่พบ template {request.TemplateId}"); if (!template.IsActive) return BadRequest("template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ (เอกสารที่สร้างไว้แล้วยังใช้ได้)"); var fieldDefs = await db.TemplateFields.AsNoTracking() .Where(f => f.TemplateId == template.Id) .OrderBy(f => f.SortOrder) .ToListAsync(ct); if (!FieldMapJson.TryNormalize(request.Values, out var values, out var valuesError)) return BadRequest($"`values` ผิด format: {valuesError}"); // validate required + type (FR-10) — field ที่ส่งเกินนิยาม ignore ตาม policy เดียวกับ FR-11 var missingRequired = fieldDefs .Where(f => f.IsRequired && (!values.TryGetValue(f.Key, out var v) || string.IsNullOrWhiteSpace(v ?? string.Empty))) .Select(f => f.Key) .ToList(); if (missingRequired.Count > 0) return BadRequest($"กรอก required fields ให้ครบ: {string.Join(", ", missingRequired)}"); var typeErrors = new List(); foreach (var def in fieldDefs) { if (!values.TryGetValue(def.Key, out var v) || string.IsNullOrWhiteSpace(v)) continue; var valid = def.Type switch { "number" => decimal.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out _), "date" => DateTimeOffset.TryParse(v, CultureInfo.InvariantCulture, DateTimeStyles.None, out _), _ => true, }; if (!valid) typeErrors.Add(def.Type == "number" ? $"`{def.Key}` ต้องเป็นตัวเลข" : $"`{def.Key}` ต้องเป็นวันที่ที่ parse ได้ (ISO-8601 เช่น 2026-08-19)"); } if (typeErrors.Count > 0) return BadRequest(string.Join("; ", typeErrors)); // โหลด template จาก MinIO → generate ไฟล์ + stamp custom properties (FR-10) Stream templateStream; try { templateStream = await storage.GetStreamAsync(minioOptions.Value.TemplatesBucket, template.ObjectKey, ct); } catch (KeyNotFoundException) { logger.LogError("Template object missing in storage templateId={TemplateId} key={ObjectKey}", template.Id, template.ObjectKey); return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template ไม่พบใน storage"); } // documentId = DocumentTrackingId ที่ stamp ลงไฟล์ — add-in ใช้ค่านี้เรียก history ต่อ var documentId = Guid.NewGuid(); byte[] fileBytes; try { await using (templateStream) { // null (JSON null) มองเป็นค่าว่าง — field "หาย" คือไม่มี key ใน map เท่านั้น fileBytes = docxGeneration.Generate( templateStream, values.ToDictionary(kv => kv.Key, kv => kv.Value ?? string.Empty), documentId); } } catch (OpenXmlPackageException ex) { logger.LogError(ex, "Template file corrupt templateId={TemplateId} key={ObjectKey}", template.Id, template.ObjectKey); return StatusCode(StatusCodes.Status502BadGateway, "ไฟล์ template เสียหาย (เปิดด้วย OpenXML ไม่ได้)"); } var now = DateTimeOffset.UtcNow; using (var hashStream = new MemoryStream(fileBytes)) { var contentHash = await hasher.ComputeSha256HexAsync(hashStream, ct); var doc = new Document { Id = documentId, FileName = BuildGeneratedFileName(template.Name, now), AddInSignature = AddInMetadata.AddInSignatureGuid, TemplateId = template.Id, CreatedAt = now, }; db.Documents.Add(doc); var version = new DocumentVersion { Id = Guid.NewGuid(), DocumentId = documentId, VersionNumber = 1, ObjectKey = $"{documentId}/v0001_{Guid.NewGuid()}.docx", // clientVersionId generate ฝั่ง server (flow นี้ไม่มี client) ContentHash = contentHash, CreatedByUserId = currentUser.UserId, CreatedAt = now, }; db.DocumentVersions.Add(version); // snapshot + field values ของ v1 (ทุก field มีค่า = missing=false ทั้งหมด) var sync = await fieldSync.StageFieldSyncAsync(doc, version.Id, values, now, ct); version.FieldsSnapshotJson = sync.SnapshotJson; try { await storage.PutAsync(minioOptions.Value.VersionsBucket, version.ObjectKey, new MemoryStream(fileBytes), DocxContentType, ct); } catch (AmazonServiceException ex) { logger.LogError(ex, "MinIO upload failed on generate documentId={DocumentId} templateId={TemplateId} userId={UserId}", documentId, template.Id, currentUser.UserId); // ถอนทั้งชุด — document ที่สร้างใหม่ยังไม่ถูกใครเห็น ลบได้สะอาด (field values ตาม FK cascade) db.Entry(version).State = EntityState.Detached; db.Entry(doc).State = EntityState.Detached; foreach (var entry in db.ChangeTracker.Entries().ToList()) entry.State = EntityState.Detached; return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); } await db.SaveChangesAsync(ct); logger.LogInformation( "Document generated documentId={DocumentId} templateId={TemplateId} versionId={VersionId} userId={UserId} bytes={Size} hash={Hash}", documentId, template.Id, version.Id, currentUser.UserId, fileBytes.Length, contentHash); return CreatedAtAction(nameof(GetVersions), new { documentId }, new GenerateDocumentResponse( documentId, version.Id, version.VersionNumber, $"/api/documents/{documentId}/versions/{version.Id}/download")); } } /// FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version) [HttpGet("{documentId:guid}/versions")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task>> GetVersions(Guid documentId, CancellationToken ct) { var exists = await db.Documents.AnyAsync(d => d.Id == documentId, ct); if (!exists) return NotFound($"ไม่พบ document {documentId}"); var rows = await db.DocumentVersions .AsNoTracking() .Where(v => v.DocumentId == documentId) .OrderByDescending(v => v.VersionNumber) .Select(v => new { v.Id, v.VersionNumber, v.CreatedAt, v.CreatedByUserId, v.ContentHash, v.FieldsSnapshotJson, }) .ToListAsync(ct); return Ok(rows.Select(r => new VersionListItem( r.Id, r.VersionNumber, r.CreatedAt, r.CreatedByUserId, r.ContentHash, ParseSnapshot(r.FieldsSnapshotJson))).ToList()); } /// FR-7 — ดาวน์โหลด version ที่ระบุ (proxy stream จาก MinIO — ไม่ expose MinIO ตรงๆ) [HttpGet("{documentId:guid}/versions/{versionId:guid}/download")] [ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task DownloadVersion(Guid documentId, Guid versionId, CancellationToken ct) { var row = await db.DocumentVersions .AsNoTracking() .Where(v => v.DocumentId == documentId && v.Id == versionId) .Join(db.Documents, v => v.DocumentId, d => d.Id, (v, d) => new { v.ObjectKey, d.FileName }) .FirstOrDefaultAsync(ct); if (row is null) return NotFound($"ไม่พบ version {versionId} ของ document {documentId}"); Stream stream; try { stream = await storage.GetStreamAsync(minioOptions.Value.VersionsBucket, row.ObjectKey, ct); } catch (KeyNotFoundException) { logger.LogError("Object missing in storage documentId={DocumentId} versionId={VersionId} key={ObjectKey}", documentId, versionId, row.ObjectKey); return NotFound("ไฟล์ของ version นี้ไม่พบใน storage"); } logger.LogInformation("Version download documentId={DocumentId} versionId={VersionId} userId={UserId}", documentId, versionId, currentUser.UserId); var downloadName = row.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) ? row.FileName : row.FileName + ".docx"; return File(stream, DocxContentType, downloadName); } /// FR-7 (ทางเลือก presigned URL อายุสั้น — spec ส่วน 9 อนุญาต) /// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac) /// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน [HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")] [ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] public async Task> GetVersionDownloadLink( Guid documentId, Guid versionId, CancellationToken ct) { var objectKey = await db.DocumentVersions .AsNoTracking() .Where(v => v.DocumentId == documentId && v.Id == versionId) .Select(v => v.ObjectKey) .FirstOrDefaultAsync(ct); if (objectKey is null) return NotFound($"ไม่พบ version {versionId} ของ document {documentId}"); var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600); var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds)); logger.LogInformation( "Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}", documentId, versionId, currentUser.UserId, expirySeconds); return Ok(new VersionDownloadLinkResponse(url, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(expirySeconds))); } /// ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request /// — ใช้ตอน MinIO fail หลัง SaveChanges แล้ว (ไม่ให้ค่าล้ำหน้า version ที่ถูกถอน) private static void RevertStagedFieldChanges( AppDbContext db, Dictionary? valuesBefore) { if (valuesBefore is null) return; foreach (var entry in db.ChangeTracker.Entries().ToList()) { if (entry.State == EntityState.Added) { entry.State = EntityState.Detached; } else if (entry.State == EntityState.Modified && valuesBefore.TryGetValue(entry.Entity.FieldKey, out var before)) { entry.Entity.Value = before.Value; entry.Entity.SourceVersionId = before.SourceVersionId; entry.Entity.UpdatedByUserId = before.UpdatedByUserId; entry.Entity.UpdatedAt = before.UpdatedAt; entry.State = EntityState.Unchanged; } } } /// ชื่อไฟล์ของเอกสารที่ generate: ชื่อ template + timestamp — ตัดอักขระที่ filename ใช้ไม่ได้ออก private static string BuildGeneratedFileName(string templateName, DateTimeOffset now) { var invalid = Path.GetInvalidFileNameChars(); var clean = new string(templateName.Select(c => invalid.Contains(c) ? '_' : c).ToArray()).Trim(); if (clean.Length == 0) clean = "document"; return $"{clean}_{now:yyyyMMdd-HHmmss}.docx"; } private static object? ParseSnapshot(string? json) { if (json is null) return null; using var doc = JsonDocument.Parse(json); return doc.RootElement.Clone(); // clone ก่อน dispose JsonDocument เพื่อให้ serialize ต่อได้ } private static bool IsUniqueViolation(DbUpdateException ex) => ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation }; }