From 25c2ce53cd84589a9b35f9a94c05303156483b86 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Wed, 19 Aug 2026 12:15:20 +0700 Subject: [PATCH] Phase 3 --- CLAUDE.md | 2 +- README.md | 74 +++- .../Controllers/DocumentsController.cs | 253 ++++++++++++-- .../Controllers/TemplatesController.cs | 325 ++++++++++++++++++ .../WordVersioning.Api/Data/AppDbContext.cs | 55 +++ ...19045552_TemplatesAndFieldSync.Designer.cs | 252 ++++++++++++++ .../20260819045552_TemplatesAndFieldSync.cs | 117 +++++++ .../Migrations/AppDbContextModelSnapshot.cs | 139 ++++++++ backend/WordVersioning.Api/Models/Document.cs | 5 +- .../Models/DocumentFieldValue.cs | 20 ++ backend/WordVersioning.Api/Models/Template.cs | 25 ++ .../WordVersioning.Api/Models/TemplateDtos.cs | 81 +++++ .../Models/TemplateField.cs | 27 ++ .../WordVersioning.Api/Models/VersionDtos.cs | 15 +- backend/WordVersioning.Api/Program.cs | 4 +- .../Services/AddInMetadata.cs | 12 + .../Services/DocxGenerationService.cs | 170 +++++++++ .../Services/DocxUploadValidation.cs | 20 ++ .../Services/FieldMapJson.cs | 64 ++++ .../Services/FieldSyncService.cs | 99 ++++++ .../Services/ObjectStorageService.cs | 9 +- docs/plans/officejs-implementation-plan.md | 44 ++- specs/spec-officejs.md | 2 +- 23 files changed, 1746 insertions(+), 68 deletions(-) create mode 100644 backend/WordVersioning.Api/Controllers/TemplatesController.cs create mode 100644 backend/WordVersioning.Api/Migrations/20260819045552_TemplatesAndFieldSync.Designer.cs create mode 100644 backend/WordVersioning.Api/Migrations/20260819045552_TemplatesAndFieldSync.cs create mode 100644 backend/WordVersioning.Api/Models/DocumentFieldValue.cs create mode 100644 backend/WordVersioning.Api/Models/Template.cs create mode 100644 backend/WordVersioning.Api/Models/TemplateDtos.cs create mode 100644 backend/WordVersioning.Api/Models/TemplateField.cs create mode 100644 backend/WordVersioning.Api/Services/AddInMetadata.cs create mode 100644 backend/WordVersioning.Api/Services/DocxGenerationService.cs create mode 100644 backend/WordVersioning.Api/Services/DocxUploadValidation.cs create mode 100644 backend/WordVersioning.Api/Services/FieldMapJson.cs create mode 100644 backend/WordVersioning.Api/Services/FieldSyncService.cs diff --git a/CLAUDE.md b/CLAUDE.md index c675b27..31eb4c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–2 เสร็จ** (scaffold + infra + backend core FR-5–8) — งานต่อคือ implement phase 3–8 ในแผน +**สถานะปัจจุบัน: Phase 0–3 เสร็จ** (scaffold + infra + backend core FR-5–8 + backend template & generation FR-9–11) — งานต่อคือ implement phase 4–8 ในแผน (4–5 add-in กับ 6 web ทำขนานกันได้) ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) diff --git a/README.md b/README.md index 02978f4..43d455c 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,12 @@ | 0 | Scaffold 3 โปรเจกต์ (addin / backend / web) | ✅ เสร็จ | | 1 | Local dev infra — docker-compose + Keycloak (realm, 3 clients + PKCE) + MinIO (buckets) | ✅ เสร็จ | | 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ | -| 3 | Template system + docx generation + field sync (FR-9–11) | ⏳ รอ implement | +| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ | | 4–5 | Add-in services + task pane UI (FR-1–4) | ⏳ รอ implement | | 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement | | 7–8 | Tests + เอกสารฉบับเต็ม | ⏳ รอ implement | -**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API สำหรับ upload / ดู history / ดาวน์โหลด version (พร้อม JWT auth จาก Keycloak) — ลองเล่นได้ผ่าน Swagger +**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) ## เอกสาร @@ -92,13 +92,27 @@ dotnet run # Swagger ที่ http://localhost:5075/swagger **API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer `): ``` -POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ (FR-5) -GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (FR-6) -GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (FR-7) +# Documents / versions (FR-5–7, 11) +POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync +GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields) +GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO) + +# Generate จาก template (FR-10) +POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata + +# Templates (FR-9) +GET /api/templates # รายการ (?includeInactive=true สำหรับ admin) +GET /api/templates/{id} # รายละเอียด + field definitions +POST /api/templates # upload .docx → detect fields อัตโนมัติ +PUT /api/templates/{id} # แก้ metadata + field definitions (label/type/required/ลำดับ) +PUT /api/templates/{id}/file # แทนที่ไฟล์ → re-detect (บอก tag ที่เพิ่ม/หาย) +DELETE /api/templates/{id} # deactivate (soft-delete) ``` - Upload เป็น multipart/form-data: `file` (.docx เท่านั้น, จำกัด 100MB) + `clientVersionId` (GUID) + `fields` (optional, JSON map tag→text สำหรับ field sync) - `documentId` ยังไม่มีในระบบ = สร้างใหม่อัตโนมัติ · `versionNumber` เพิ่มอัตโนมัติต่อเอกสาร · เก็บ SHA256 ของทุก version +- Field sync policy (FR-11): field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request** · ส่งเกินนิยาม → ignore +- Generate (FR-10): backend เปิด template ด้วย OpenXML → แทนที่ค่าใน SDT ที่ **run level** (รักษา formatting) → stamp custom properties (`SavedByAddIn`, `DocumentTrackingId`) → upload เป็น v1 → response `{ documentId, versionId, versionNumber, downloadUrl }` — เปิดไฟล์ใน Word แล้ว add-in (Phase 4–5) จะรู้จักทันที ### ขอ token ทดสอบ (dev) @@ -121,6 +135,56 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \ # → { "versionId": "...", "versionNumber": 1 } ``` +### ลองเล่น template → generate → field sync (Phase 3) + +**0) เตรียมไฟล์ template** — เขียน .docx ใน Word แล้วแทรก content controls ในจุดที่จะเป็น field (Developer tab → Rich Text Content Control → Properties ตั้ง **Tag** ให้ unique ต่อไฟล์ เช่น `employee_name`, `salary`) + +**1) Upload template → ระบบ detect fields อัตโนมัติ:** + +```bash +curl -X POST http://localhost:5075/api/templates \ + -H "Authorization: Bearer $TOKEN" \ + -F "name=สัญญาจ้างงาน" \ + -F "file=@template.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" +# → { "id": "...", "fields": [ { "key": "employee_name", ... }, ... ] } +``` + +**2) ตั้งค่า field definitions** (label ที่แสดงในฟอร์ม / ชนิด `text|number|date` / required / ลำดับ): + +```bash +curl -X PUT http://localhost:5075/api/templates/$TPL_ID \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d '{"fields": [ + { "key": "employee_name", "label": "ชื่อ-นามสกุล", "type": "text", "isRequired": true, "sortOrder": 0 }, + { "key": "salary", "label": "เงินเดือน", "type": "number", "isRequired": true, "sortOrder": 1 } ]}' +``` + +**3) Generate เอกสาร — ได้ .docx ที่เป็น version 1 พร้อม metadata:** + +```bash +curl -X POST http://localhost:5075/api/documents/generate \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d "{\"templateId\":\"$TPL_ID\",\"values\":{\"employee_name\":\"สมชาย ใจดี\",\"salary\":\"50000\"}}" +# → { "documentId": "...", "versionId": "...", "versionNumber": 1, "downloadUrl": "/api/documents/.../download" } + +curl -H "Authorization: Bearer $TOKEN" -o ไฟล์ที่สร้าง.docx \ + "http://localhost:5075$DOWNLOAD_URL" +``` + +เปิดไฟล์ที่ได้ตรวจได้ว่า: ค่าถูกทุก field (formatting ของ template คงเดิม) และมี custom properties `SavedByAddIn` / `DocumentTrackingId` (unzip ดู `docProps/custom.xml` หรือ Word → File → Info → Properties → Advanced Properties) + +**4) Field sync ตอนสร้าง version ถัดไป** — upload v2 พร้อมแนบ `fields` (map tag→text ที่ extract จาก content controls — ในอนาคตฝั่ง add-in ทำให้อัตโนมัติ): + +```bash +curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \ + -H "Authorization: Bearer $TOKEN" \ + -F "file=@ไฟล์แก้แล้ว.docx;type=application/vnd.openxmlformats-officedocument.wordprocessingml.document" \ + -F "clientVersionId=$(uuidgen)" \ + -F 'fields={"employee_name":"สมชาย ใจดี มากกว่าเดิม","salary":"60000"}' +``` + +แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error) + ### 3) Add-in และ Web Portal (ยังเป็น scaffold) ```bash diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs index 91da2f5..d3901f0 100644 --- a/backend/WordVersioning.Api/Controllers/DocumentsController.cs +++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs @@ -1,5 +1,7 @@ +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; @@ -11,7 +13,8 @@ using WordVersioning.Api.Services; namespace WordVersioning.Api.Controllers; -/// FR-5 (upload version), FR-6 (history), FR-7 (download) + รับ fields สำหรับ FR-11 +/// 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")] @@ -19,17 +22,21 @@ public class DocumentsController( AppDbContext db, IObjectStorageService storage, IHashService hasher, + IDocxGenerationService docxGeneration, + IFieldSyncService fieldSync, ICurrentUser currentUser, IOptions minioOptions, ILogger logger) : ControllerBase { - public const string DocxContentType = - "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + 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-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)] @@ -45,30 +52,12 @@ public class DocumentsController( return BadRequest("`clientVersionId` ต้องเป็น GUID"); if (request.File.Length > MaxUploadBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB"); - if (!IsValidDocx(request.File)) + if (!DocxUploadValidation.IsValidDocx(request.File)) return BadRequest("รองรับเฉพาะไฟล์ .docx (Word document)"); // parse fields map (FR-11) — optional, ผิด format คือ 400 - string? fieldsSnapshotJson = null; - if (!string.IsNullOrWhiteSpace(request.Fields)) - { - try - { - using var fieldsDoc = JsonDocument.Parse(request.Fields); - if (fieldsDoc.RootElement.ValueKind != JsonValueKind.Object) - return BadRequest("`fields` ต้องเป็น JSON object รูป { tag: text }"); - foreach (var prop in fieldsDoc.RootElement.EnumerateObject()) - { - if (!IsScalar(prop.Value.ValueKind)) - return BadRequest($"`fields.{prop.Name}` ต้องเป็นค่า scalar (string/number/bool/null)"); - } - fieldsSnapshotJson = fieldsDoc.RootElement.GetRawText(); - } - catch (JsonException) - { - return BadRequest("`fields` ไม่ใช่ JSON ที่ถูกต้อง"); - } - } + if (!FieldMapJson.TryParse(request.Fields, out var fieldMap, out var fieldsError)) + return BadRequest(fieldsError); var now = DateTimeOffset.UtcNow; var fileName = request.File.FileName; @@ -102,6 +91,26 @@ public class DocumentsController( 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++) @@ -114,7 +123,7 @@ public class DocumentsController( version = new DocumentVersion { - Id = Guid.NewGuid(), + Id = versionId, // ใช้ id เดิมทุก retry — field sync ที่ stage ไว้อ้างอิง id นี้ DocumentId = documentId, VersionNumber = nextNumber, ObjectKey = $"{documentId}/v{nextNumber:D4}_{request.ClientVersionId}.docx", @@ -166,6 +175,7 @@ public class DocumentsController( db.DocumentVersions.Remove(version); if (createdDoc) db.Documents.Remove(doc); + RevertStagedFieldChanges(db, fieldValuesBefore); await db.SaveChangesAsync(CancellationToken.None); return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); } @@ -178,6 +188,153 @@ public class DocumentsController( 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)] @@ -243,20 +400,40 @@ public class DocumentsController( return File(stream, DocxContentType, downloadName); } - private static bool IsValidDocx(IFormFile file) => - file.FileName.EndsWith(".docx", StringComparison.OrdinalIgnoreCase) - && IsAcceptableContentType(file.ContentType); + /// ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request + /// — ใช้ตอน MinIO fail หลัง SaveChanges แล้ว (ไม่ให้ค่าล้ำหน้า version ที่ถูกถอน) + private static void RevertStagedFieldChanges( + AppDbContext db, Dictionary? valuesBefore) + { + if (valuesBefore is null) + return; - // spec: reject content-type ที่ไม่ใช่ docx — ยอมรับ octet-stream/zip ด้วยเพราะ client บางตัว - // (เช่น curl -F) ไม่ set ชนิดไฟล์ให้ ส่วนชื่อไฟล์ต้องลง .docx เสมอ (เช็คข้างบน) - private static bool IsAcceptableContentType(string contentType) => - string.IsNullOrWhiteSpace(contentType) - || contentType.Equals(DocxContentType, StringComparison.OrdinalIgnoreCase) - || contentType.Equals("application/octet-stream", StringComparison.OrdinalIgnoreCase) - || contentType.Equals("application/zip", StringComparison.OrdinalIgnoreCase); + 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; + } + } + } - private static bool IsScalar(JsonValueKind kind) => - kind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null; + /// ชื่อไฟล์ของเอกสารที่ 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) { diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs new file mode 100644 index 0000000..18f35a2 --- /dev/null +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -0,0 +1,325 @@ +using Amazon.Runtime; +using DocumentFormat.OpenXml.Packaging; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using WordVersioning.Api.Data; +using WordVersioning.Api.Models; +using WordVersioning.Api.Services; + +namespace WordVersioning.Api.Controllers; + +/// FR-9 — template CRUD + auto-detect fields (SDT tags) ตอน upload/replace ไฟล์ +[ApiController] +[Authorize] +[Route("api/templates")] +public class TemplatesController( + AppDbContext db, + IObjectStorageService storage, + IDocxGenerationService docxGeneration, + ICurrentUser currentUser, + IOptions minioOptions, + ILogger logger) : ControllerBase +{ + private const long MaxUploadBytes = 100L * 1024 * 1024; // 100MB ตาม spec + + /// รายการ template — default เฉพาะ active (หน้าเลือกสร้างเอกสาร), ส่ง includeInactive=true สำหรับหน้า admin + [HttpGet] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + public async Task>> List( + [FromQuery] bool includeInactive = false, CancellationToken ct = default) + { + var query = db.Templates.AsNoTracking().AsQueryable(); + if (!includeInactive) + query = query.Where(t => t.IsActive); + + var rows = await query + .OrderBy(t => t.Name) + .Select(t => new + { + t.Id, + t.Name, + t.Description, + t.IsActive, + FieldCount = t.Fields.Count, + t.CreatedAt, + t.UpdatedAt, + }) + .ToListAsync(ct); + + return Ok(rows.Select(r => new TemplateListItem( + r.Id, r.Name, r.Description, r.IsActive, r.FieldCount, r.CreatedAt, r.UpdatedAt)).ToList()); + } + + /// รายละเอียด template พร้อม field definitions (สำหรับหน้า edit) + [HttpGet("{id:guid}")] + [ProducesResponseType(typeof(TemplateDetail), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> Get(Guid id, CancellationToken ct) + { + var template = await db.Templates.AsNoTracking() + .Include(t => t.Fields) + .FirstOrDefaultAsync(t => t.Id == id, ct); + if (template is null) + return NotFound($"ไม่พบ template {id}"); + + return Ok(ToDetail(template)); + } + + /// upload template ใหม่ — parse SDT tags ออกมาเป็น field list อัตโนมัติ + /// (สร้าง TemplateField พร้อมค่า default ทันที: label = tag, type = text, required = false) + [HttpPost] + [RequestSizeLimit(MaxUploadBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)] + [ProducesResponseType(typeof(CreateTemplateResponse), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task Create([FromForm] CreateTemplateRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest("ต้องระบุ `name`"); + if (request.File is null || request.File.Length == 0) + return BadRequest("ต้องแนบไฟล์ `file` (docx)"); + if (request.File.Length > MaxUploadBytes) + return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB"); + if (!DocxUploadValidation.IsValidDocx(request.File)) + return BadRequest("รองรับเฉพาะไฟล์ .docx (Word document)"); + + // detect fields จากไฟล์ที่อัปโหลด (IFormFile spool ลง disk แล้ว — stream seek ได้) + List tags; + try + { + await using var detectStream = request.File.OpenReadStream(); + tags = docxGeneration.DetectFieldTags(detectStream).ToList(); + } + catch (OpenXmlPackageException ex) + { + logger.LogWarning(ex, "Template upload: ไฟล์เปิดไม่ได้ userId={UserId} fileName={FileName}", + currentUser.UserId, request.File.FileName); + return BadRequest("ไฟล์ .docx ไม่ถูกต้อง (เปิดด้วย OpenXML ไม่ได้)"); + } + + var now = DateTimeOffset.UtcNow; + var template = new Template + { + Id = Guid.NewGuid(), + Name = request.Name.Trim(), + Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), + ObjectKey = string.Empty, // จะเติมหลัง upload สำเร็จ — ไม่บันทึก row ที่ชี้ไฟล์ที่ไม่มีจริง + IsActive = true, + CreatedAt = now, + UpdatedAt = now, + }; + var sortOrder = 0; + foreach (var tag in tags) + { + template.Fields.Add(new TemplateField + { + TemplateId = template.Id, + Key = tag, + Label = tag, // default — admin ไปตั้งต่อผ่าน PUT + Type = "text", + IsRequired = false, + SortOrder = sortOrder++, + }); + } + + // upload ไฟล์ก่อน insert row — ถ้า DB fail ทีหลังจะเหลือ object กำพร้าใน bucket (log ไว้ ไม่กระทบกระเสีย) + var objectKey = $"{template.Id}/{now:yyyyMMddHHmmss}.docx"; + try + { + await using var uploadStream = request.File.OpenReadStream(); + uploadStream.Seek(0, SeekOrigin.Begin); + await storage.PutAsync(minioOptions.Value.TemplatesBucket, objectKey, uploadStream, + DocxUploadValidation.DocxContentType, ct); + } + catch (AmazonServiceException ex) + { + logger.LogError(ex, "Template file upload failed templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); + } + + template.ObjectKey = objectKey; + db.Templates.Add(template); + await db.SaveChangesAsync(ct); + + logger.LogInformation( + "Template created templateId={TemplateId} name={TemplateName} fields={FieldCount} userId={UserId}", + template.Id, template.Name, template.Fields.Count, currentUser.UserId); + + return CreatedAtAction(nameof(Get), new { id = template.Id }, + new CreateTemplateResponse(template.Id, FieldDtos(template))); + } + + /// แก้ metadata + บันทึก field definitions (label/type/required/sortOrder) + /// — key ต้องเป็นชุดที่ detect ไว้เท่านั้น จะเพิ่ม/ลบ key ต้องผ่านการ replace ไฟล์ + [HttpPut("{id:guid}")] + [ProducesResponseType(typeof(TemplateDetail), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task Update(Guid id, [FromBody] UpdateTemplateRequest request, CancellationToken ct) + { + var template = await db.Templates + .Include(t => t.Fields) + .FirstOrDefaultAsync(t => t.Id == id, ct); + if (template is null) + return NotFound($"ไม่พบ template {id}"); + + if (request.Name is not null) + { + if (string.IsNullOrWhiteSpace(request.Name)) + return BadRequest("`name` ห้ามว่าง"); + template.Name = request.Name.Trim(); + } + if (request.Description is not null) + template.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(); + if (request.IsActive.HasValue) + template.IsActive = request.IsActive.Value; + + if (request.Fields is not null) + { + var unknown = request.Fields + .Select(f => f.Key) + .Distinct() + .Except(template.Fields.Select(f => f.Key)) + .ToList(); + if (unknown.Count > 0) + return BadRequest($"ไม่รู้จัก field key: {string.Join(", ", unknown)} (key มาจากไฟล์ — เพิ่ม/ลบต้อง replace ไฟล์)"); + + foreach (var def in request.Fields) + { + if (!TemplateField.ValidTypes.Contains(def.Type)) + return BadRequest($"type ของ `{def.Key}` ต้องเป็น text/number/date (ได้ `{def.Type}`)"); + if (string.IsNullOrWhiteSpace(def.Label)) + return BadRequest($"label ของ `{def.Key}` ห้ามว่าง"); + + var row = template.Fields.First(f => f.Key == def.Key); + row.Label = def.Label.Trim(); + row.Type = def.Type; + row.IsRequired = def.IsRequired; + row.SortOrder = def.SortOrder; + } + } + + template.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + logger.LogInformation("Template updated templateId={TemplateId} userId={UserId}", template.Id, currentUser.UserId); + return Ok(ToDetail(template)); + } + + /// แทนที่ไฟล์ template — re-detect fields: tag ใหม่เพิ่มเป็น default, tag ที่หายไปลบนิยามทิ้ง + /// (document เก่าที่สร้างไปแล้วไม่กระทบ — field values เก็บแยกที่ตัว document) + [HttpPut("{id:guid}/file")] + [RequestSizeLimit(MaxUploadBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = MaxUploadBytes)] + [ProducesResponseType(typeof(ReplaceTemplateFileResponse), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task ReplaceFile(Guid id, [FromForm] ReplaceTemplateFileRequest request, CancellationToken ct) + { + var template = await db.Templates + .Include(t => t.Fields) + .FirstOrDefaultAsync(t => t.Id == id, ct); + if (template is null) + return NotFound($"ไม่พบ template {id}"); + if (request.File is null || request.File.Length == 0) + return BadRequest("ต้องแนบไฟล์ `file` (docx)"); + if (request.File.Length > MaxUploadBytes) + return StatusCode(StatusCodes.Status413PayloadTooLarge, $"ไฟล์ใหญ่เกิน {MaxUploadBytes / (1024 * 1024)}MB"); + if (!DocxUploadValidation.IsValidDocx(request.File)) + return BadRequest("รองรับเฉพาะไฟล์ .docx (Word document)"); + + List newTags; + try + { + await using var detectStream = request.File.OpenReadStream(); + newTags = docxGeneration.DetectFieldTags(detectStream).ToList(); + } + catch (OpenXmlPackageException ex) + { + logger.LogWarning(ex, "Template replace: ไฟล์เปิดไม่ได้ templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return BadRequest("ไฟล์ .docx ไม่ถูกต้อง (เปิดด้วย OpenXML ไม่ได้)"); + } + + var existingKeys = template.Fields.Select(f => f.Key).ToList(); + var addedTags = newTags.Except(existingKeys).ToList(); + var removedTags = existingKeys.Except(newTags).ToList(); + + var now = DateTimeOffset.UtcNow; + var objectKey = $"{template.Id}/{now:yyyyMMddHHmmss}.docx"; + try + { + await using var uploadStream = request.File.OpenReadStream(); + uploadStream.Seek(0, SeekOrigin.Begin); + await storage.PutAsync(minioOptions.Value.TemplatesBucket, objectKey, uploadStream, + DocxUploadValidation.DocxContentType, ct); + } + catch (AmazonServiceException ex) + { + logger.LogError(ex, "Template file upload failed templateId={TemplateId} userId={UserId}", + template.Id, currentUser.UserId); + return StatusCode(StatusCodes.Status502BadGateway, "อัปโหลดไฟล์ขึ้น storage ไม่สำเร็จ"); + } + + template.ObjectKey = objectKey; + template.UpdatedAt = now; + + foreach (var removed in template.Fields.Where(f => removedTags.Contains(f.Key)).ToList()) + template.Fields.Remove(removed); + var nextSort = template.Fields.Count == 0 ? 0 : template.Fields.Max(f => f.SortOrder) + 1; + foreach (var tag in addedTags) + { + // ห้าม set Id เอง — Guid PK ที่มีค่าทำให้ EF ตีเป็น Modified (update แถวที่ไม่มี) แทน Added + template.Fields.Add(new TemplateField + { + TemplateId = template.Id, + Key = tag, + Label = tag, + Type = "text", + IsRequired = false, + SortOrder = nextSort++, + }); + } + + await db.SaveChangesAsync(ct); + + logger.LogInformation( + "Template file replaced templateId={TemplateId} userId={UserId} added={AddedTags} removed={RemovedTags}", + template.Id, currentUser.UserId, string.Join(",", addedTags), string.Join(",", removedTags)); + + return Ok(new ReplaceTemplateFileResponse(template.Id, FieldDtos(template), addedTags, removedTags)); + } + + /// deactivate (soft-delete) — document เก่าที่อ้างอยู่ยังใช้ได้ต่อ แต่สร้างเอกสารใหม่ไม่ได้ + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task Deactivate(Guid id, CancellationToken ct) + { + var template = await db.Templates.FirstOrDefaultAsync(t => t.Id == id, ct); + if (template is null) + return NotFound($"ไม่พบ template {id}"); + + template.IsActive = false; + template.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + logger.LogInformation("Template deactivated templateId={TemplateId} userId={UserId}", template.Id, currentUser.UserId); + return NoContent(); + } + + private static TemplateDetail ToDetail(Template template) => new( + template.Id, template.Name, template.Description, template.IsActive, + template.CreatedAt, template.UpdatedAt, FieldDtos(template)); + + private static List FieldDtos(Template template) => template.Fields + .OrderBy(f => f.SortOrder) + .Select(f => new TemplateFieldDto(f.Id, f.Key, f.Label, f.Type, f.IsRequired, f.SortOrder)) + .ToList(); +} diff --git a/backend/WordVersioning.Api/Data/AppDbContext.cs b/backend/WordVersioning.Api/Data/AppDbContext.cs index 71ccda6..702f2f0 100644 --- a/backend/WordVersioning.Api/Data/AppDbContext.cs +++ b/backend/WordVersioning.Api/Data/AppDbContext.cs @@ -9,6 +9,12 @@ public class AppDbContext(DbContextOptions options) : DbContext(op public DbSet DocumentVersions => Set(); + public DbSet