eCourt-Doc-Edit/specs/spec-officejs.md
Suphonchai Phoonsawat 83a949cfa0 v9.2 follow-up: skip ms-word on Mac by default + sideload notes
- Web: ยิง ms-word:ofe คู่กับการดาวน์โหลดเฉพาะ non-Mac โดย default —
  บน Mac ไม่ยิงเพราะ Word 16.101+ เมิน URL แล้วเด้งหน้าเลือกไฟล์
  ทุกครั้งจนรำคาญ (override ด้วย NUXT_PUBLIC_OPEN_IN_WORD=on|off)
- CLAUDE.md: จดสูตร sideload บน Mac — ปิด Word ก่อน npm start
  (Word อ่าน manifest ตอน startup เท่านั้น), ปล่อย dev server รันทิ้ง,
  แก้โค้ดใช้ hot reload
- Spec/plan sync + web 60/60 + build; QA reviewer PASS (1 round,
  minors 3 — แก้ 2 ปฏิเสธ 1 พร้อมเหตุผล)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:00:41 +07:00

66 KiB
Raw Blame History

Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)

ฉบับแก้ไข v9.2 (2026-09-10): Word for Mac 16.101+ (ก.ย. 2026) ทำลาย ms-word:ofe บนเครื่องที่ทดสอบ — ทุกรูปแบบถูกเมินแบบไม่ deterministic (https ไม่มี query เคยสำเร็จ 1 ครั้ง; ยืนยันด้วยการทดสอบ 6 รอบบนเครื่องจริง) → เปลี่ยน UX เปิดไฟล์เป็น download-first (ดาวน์โหลดทันที + ยิง ms-word ควบคู่แบบ best-effort เผื่อเครื่องที่ยังใช้ได้) + download-link เปลี่ยนจาก presigned ของ MinIO เป็น open-link ของ backend เอง (HMAC อายุสั้นฝังใน path ไม่มี query, วิ่งผ่าน https ของ backend, [AllowAnonymous] โดยใช้ลายเซ็นแทน Bearer) · เอา heuristic สลับแอปออก (โดนหลอกด้วยหน้าเลือกไฟล์ของ Word จนไม่ดาวน์โหลด) ฉบับแก้ไข v9 (2026-09-10): ปรับ FR-16 ตามผลใช้งานจริง — ไฟล์ร่างเก็บชั่วคราวใน MinIO (prefix drafts/ + lifecycle ลบรายวัน) เพื่อคืน presigned URL ให้เว็บเปิดใน Word ทันที (เดิมคืนไฟล์ตรงๆ ผู้ใช้ต้องเปิดเอง) + ค่าที่แทนที่เขียนที่ run ข้อความ placeholder จริง (complex script ไทย) แก้การตัดคำภาษาไทย ฉบับแก้ไข v8 (2026-09-10): เพิ่ม FR-16 — ไฟล์ร่าง (draft): สร้าง .docx จาก template แบบ stateless (ไม่สร้าง Document/version, stamp property TemplateId ตัวเดียว) + ผูก template ตอน Start Versioning ผ่าน form field templateId ทางเลือก — ไฟล์ร่างเริ่ม versioning ที่ v1 โดยไม่มี v0 ฉบับแก้ไข v7 (2026-09-08): FR-15 — PDF จาก Word แบบ best-effort; generate เป็น v0, add-in เริ่ม v1; เว็บเปิด Word/PDF พร้อมดาวน์โหลดสำรอง ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): text เลือก textbox/textarea ได้, number/date ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow ฉบับแก้ไข v5 (2026-08-20): เพิ่ม FR-13 — คำอธิบายต่อ version ("commit message") พิมพ์ได้ตอนสร้าง version ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า ฉบับแก้ไข v4 (2026-08-20): เพิ่ม FR-12 — รายการเอกสารต่อ template + document detail + re-generate (Web Portal) ฉบับแก้ไข v3 (2026-08-19): เพิ่มข้อจำกัดแพลตฟอร์มที่พบจากการทดสอบจริงบน Word for Mac (dialog บังคับ HTTPS, mixed content, รูปแบบ slice.data หลายแบบ, webview ทิ้ง blob download) + กลไกดาวน์โหลด version ผ่าน presigned link ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ docs/plans/officejs-implementation-plan.md

1. ภาพรวม

สร้างระบบ 3 ส่วนที่ทำงานร่วมกัน:

  1. Office Add-in (Office.js, TypeScript) — รันได้บน Word Desktop (Windows/Mac) และ Word Online โดยไม่ต้อง build แยก
  2. .NET 8 Web API Backend — เก็บ version metadata ใน DB และไฟล์ .docx จริงใน MinIO (S3-compatible object storage) + ระบบ template และ generate เอกสาร
  3. Web Portal (Nuxt.js + TailwindCSS) — เว็บแยกสำหรับจัดการ template และกรอก form สร้างเอกสารครั้งแรกจาก template โดยเป็น version 0 ฉบับตั้งต้น

ความสามารถหลัก (เทียบเท่า VSTO แต่ปรับ flow ให้เข้ากับข้อจำกัดของแพลตฟอร์ม):

  1. ตรวจจับได้ว่าไฟล์ .docx ที่เปิดอยู่เคยถูกบันทึกโดย add-in นี้หรือไม่
  2. มีปุ่ม "Save & Version" ใน Task Pane ให้ผู้ใช้กดเพื่อยืนยันสร้าง version (แทนการดัก native save เพราะ Office.js ไม่มี before-save event)
  3. เมื่อกด Save & Version จะ stamp tag ลง metadata ของไฟล์ + อัปโหลด snapshot ไป backend + extract ค่าจาก content controls ไป update fields ใน DB ให้ sync (FR-11)
  4. Backend เก็บ version history แบบ query ได้, ดาวน์โหลด version ย้อนหลังได้
  5. จัดการ template เอกสาร (.docx ที่มี content controls) ผ่านหน้าเว็บ — upload แล้วระบบ detect fields อัตโนมัติ (FR-9)
  6. สร้างเอกสารครั้งแรกจาก template: กรอก form บนเว็บ → backend generate .docx + stamp metadata + สร้าง version 0 ฉบับตั้งต้น → เปิดใน Word แล้ว add-in รู้จักทันที (FR-10)

ขอบเขต: รองรับ Word Desktop (Win/Mac) + Word on the Web


2. ⚠️ ข้อจำกัดของแพลตฟอร์มที่ต้องออกแบบรองรับ

  • ไม่มี native "before save" / "before close" event ที่ cancel ได้ ใน Word JS API (ต่างจาก VSTO) → ห้ามออกแบบ flow ที่พึ่งพาการดัก Ctrl+S หรือ File>Save ของผู้ใช้ ต้องใช้ explicit action (ปุ่มใน task pane / ribbon command) แทน
  • Custom XML Parts แก้ไขตรงๆ ไม่ได้ — ต้อง delete() ของเดิมแล้ว add() ใหม่ทุกครั้งที่จะอัปเดต
  • Custom XML Parts มี known bug บางแพลตฟอร์ม (เช่น ไม่ persist หลัง save/reopen บน Excel/Word for Mac บาง build) → ใช้เป็น secondary storage เท่านั้น อย่าพึ่งเป็นแหล่งข้อมูลเดียว
  • Custom Document Properties เสถียรกว่าและ cross-platform ดีกว่า → ใช้เป็น primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้หรือไม่
  • Manifest ต้อง support ทั้ง requirement set ที่ document.customXmlParts, document.properties.customProperties, และ document.save() (WordApi 1.5+) ใช้งานได้

ข้อจำกัดเพิ่มเติมจากการทดสอบจริงบน Word for Mac (v3):

  • Office Dialog API ปฏิเสธ URL ที่เป็น http:// โดยสมบูรณ์ (error "The HTTP protocol is not supported. Use HTTPS instead") → หน้า login ของ IdP ต้องเสิร์ฟ HTTPS เสมอแม้ตอน dev
  • Webview ของ host (โดยเฉพาะ WKWebView ใน Word for Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ (mixed content — error "Load failed", request ไม่เคยถึง server, CORS ไม่เกี่ยว) → ทุก service ที่ task pane (หน้า https) จะเรียกต้องเป็น HTTPS ทั้งหมด
  • ข้อมูล slice จาก getSliceAsync มีได้หลายรูปแบบตาม host/แพลตฟอร์ม — ArrayBuffer, typed-array view, base64 string, หรือ number[] ธรรมดา → ต้อง normalize ครบทุกกรณีก่อนประกอบเป็นไฟล์
  • Webview ของ host ทิ้ง blob download (a[download] + object URL) แบบเงียบๆ — ไฟล์ไม่ถูกเขียนที่ไหนเลย → การดาวน์โหลดไฟล์จาก add-in ต้องขอ presigned URL อายุสั้นจาก backend แล้วเปิดในเบราว์เซอร์ของเครื่อง (Office.context.ui.openBrowserWindow) แทน

3. เทคโนโลยี

Add-in (frontend)

  • Office Add-in project scaffold จาก yo office (Yeoman generator)
  • TypeScript + React (task pane UI)
  • Office.js / Word JavaScript API (office-js, ใช้ @types/office-js)
  • Fluent UI React components (ให้เข้ากับ Office look & feel)

Web Portal (เว็บแยก) — เพิ่มเติม v2

  • Nuxt.js (Vue 3) + TailwindCSS — ทุกหน้ารองรับ light/dark theme (toggle + persist + default ตาม prefers-color-scheme, Tailwind darkMode: 'class')
  • Auth ผ่าน Keycloak client word-versioning-web (public + PKCE) ด้วย OIDC browser redirect ปกติ — เป็น top-level page จึงไม่ต้องใช้ Office Dialog API
  • Dev server ที่ port 5173 (ห้ามใช้ 3000 เพราะชนกับ add-in dev server)

Backend

  • ASP.NET Core 8 Web API
  • Entity Framework Core + PostgreSQL (provider Npgsql.EntityFrameworkCore.PostgreSQL)
  • DocumentFormat.OpenXml (OpenXML SDK) — parse content controls จาก template, แทนที่ค่าตอน generate, stamp Custom Properties ลงไฟล์
  • MinIO สำหรับเก็บไฟล์ .docx จริง — ใช้ AWS SDK for .NET (AWSSDK.S3) เชื่อมต่อผ่าน S3-compatible API (MinIO รองรับ S3 API เต็มรูปแบบ) โดย config endpoint ชี้ไปที่ MinIO instance แทน AWS จริง
  • ออกแบบผ่าน interface (IObjectStorageService) เพื่อ swap ไป provider อื่นที่รองรับ S3 API ได้ในอนาคต (AWS S3, Azure Blob ผ่าน S3-compat gateway, ฯลฯ) โดยไม่กระทบ business logic
  • Auth: Keycloak (OpenID Connect) — Add-in ทำ OIDC login flow ผ่าน Keycloak แล้วได้ access token (JWT) มาแนบกับทุก request ไป backend, backend validate token ผ่าน Keycloak's public key (JWKS endpoint)

4. โครงสร้างโปรเจกต์ที่ต้องการ

/word-versioning-addin
  /addin                          # Office.js task pane project (yo office scaffold)
    manifest.xml
    /src
      /taskpane
        taskpane.html
        taskpane.ts
        auth-callback.html        # หน้ารับ redirect จาก Keycloak ภายใน Dialog API
        /components
          VersionHistoryPanel.tsx
          ConfirmVersionDialog.tsx
      /services
        metadataService.ts        # custom properties + custom XML part logic
        versioningService.ts      # เรียก backend API
        fileService.ts            # getFileAsync/slice → Blob
        authService.ts            # Keycloak OIDC + PKCE login flow, token refresh
        apiClient.ts               # แนบ Authorization: Bearer <token> ทุก request
      /models
        versionRecord.ts
    package.json
    webpack.config.js

  /backend
    /WordVersioning.Api
      Program.cs
      /Controllers
        DocumentsController.cs   # FR-5 ถึง FR-7 + generate (FR-10) + field sync (FR-11)
        TemplatesController.cs   # FR-9 template CRUD + auto-detect fields
      /Models
        Document.cs              # + TemplateId
        DocumentVersion.cs       # + FieldsSnapshotJson
        Template.cs
        TemplateField.cs
        DocumentFieldValue.cs
      /Data
        AppDbContext.cs
      /Services
        ObjectStorageService.cs   # wrapper เรียก MinIO ผ่าน AWSSDK.S3 (bucket docx-versions + docx-templates)
        HashService.cs
        DocxGenerationService.cs  # FR-10: แทนที่ค่าใน SDT + stamp custom properties
      appsettings.json          # เก็บ MinIO endpoint/keys, Keycloak Authority/Audience
    WordVersioning.Api.csproj

  /web                           # Web Portal — เพิ่มเติม v2 (Nuxt.js + TailwindCSS)
    nuxt.config.ts               # devServer port 5173
    tailwind.config.ts           # darkMode: 'class'
    /pages                       # เลือก template สร้างเอกสาร / จัดการ template / dynamic form
                                 # + รายการเอกสารต่อ template + detail เอกสาร + re-generate (FR-12, v4)
    /composables                 # useAuth (PKCE redirect), useApi, useTheme (light/dark)
    /utils                       # oidc (PKCE helpers), fieldValidation, snapshot — pure functions ไว้ unit test
    package.json

  /keycloak
    realm-export.json           # pre-configured realm/client สำหรับ local dev import อัตโนมัติ

  docker-compose.yml            # รัน MinIO + Keycloak + backend + DB สำหรับ local dev

5. Functional Requirements — Add-in (Frontend)

FR-1: ตรวจจับไฟล์ที่เคยบันทึกจาก add-in นี้

  • เมื่อ Office.onReady() ให้เช็คสถานะ login ก่อน (มี valid access token ใน session หรือไม่) — ถ้ายังไม่ login ให้แสดงปุ่ม "Sign in" (trigger login flow ตาม FR-8) ก่อนเข้าถึงฟีเจอร์อื่น
  • ตรวจ Custom Document Property SavedByAddIn เทียบกับค่าคงที่ ADDIN_SIGNATURE_GUID
  • ถ้าตรง → โหลด DocumentTrackingId จาก properties แล้วเรียก backend (แนบ Bearer token) เพื่อดึง version history มาแสดงใน task pane
  • ถ้าไม่ตรง/ไม่มี → แสดง UI "เริ่มต้น track เอกสารนี้" (ปุ่ม "Start Versioning This Document")

FR-2: ปุ่ม "Save & Version" (แทนการดัก native save)

Flow เมื่อกด:

  1. แสดง dialog ยืนยัน ("สร้าง version ใหม่ของเอกสารนี้หรือไม่?")
  2. Stamp Custom Document Properties: SavedByAddIn, DocumentTrackingId (สร้างใหม่ถ้ายังไม่มี), LastVersionId, LastVersionTimestamp
  3. อัปเดต Custom XML Part สำหรับ version history (delete-then-add pattern)
  4. เรียก context.document.save(Word.SaveBehavior.save) เพื่อ trigger native save
  5. ดึงไฟล์ปัจจุบันด้วย File/Slice API (Office.context.document.getFileAsync)
  6. อัปโหลดไปยัง backend endpoint POST /api/documents/{documentId}/versions พร้อมแนบค่า fields ที่ extract จาก content controls ของเอกสาร (FR-11)
  7. แสดงผลลัพธ์ (success/error) ใน task pane, refresh version history list

FR-3: Soft reminder เมื่อยังไม่ได้ version

  • เนื่องจากดัก native save ไม่ได้ ให้ใช้ Document.addHandlerAsync(Office.EventType.DocumentSelectionChanged) หรือ polling เบาๆ (เช่น debounce ทุก 30 วินาที) ตรวจ context.document.saved — ถ้า false ต่อเนื่องนานเกิน threshold ให้แสดง banner ใน task pane เตือนว่า "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ใช่ hard block)

FR-4: Version History Panel

  • แสดงรายการ version จาก backend: version number, timestamp, ผู้สร้าง
  • ปุ่ม "Download this version" → เรียก GET /api/documents/{documentId}/versions/{versionId}/download

6. Functional Requirements — Backend API, Template System & Web Portal

FR-5: Upload version

  • v7: รับ multipart pdf ทางเลือก; response เพิ่ม hasPdf; upload เริ่ม v1 หรือ max+1 ต่อจาก v0 ที่ generate
POST /api/documents/{documentId}/versions
Content-Type: multipart/form-data
Body: file (docx binary), clientVersionId (guid), pdf? (PDF binary), fields?, message?
  • ถ้า documentId ยังไม่มีใน DB ให้สร้าง Document record ใหม่
  • อัปโหลดไฟล์ไป MinIO bucket (เช่น docx-versions) ที่ object key: {documentId}/v{versionNumber:D4}_{clientVersionId}.docx
  • คำนวณ SHA256 hash ของไฟล์ เก็บลง DB (ใช้ตรวจสอบไฟล์ซ้ำ/integrity ภายหลัง)
  • บันทึก DocumentVersion record (version number = auto increment ต่อ document)
  • Response: { versionId, versionNumber, hasPdf }

FR-6: ดึง version history

GET /api/documents/{documentId}/versions
  • คืน list ของ version เรียงจากใหม่ไปเก่า พร้อม hasPdf ระบุว่ามีสำเนา PDF

FR-7: ดาวน์โหลด version ที่ระบุ

  • v7: ทั้ง /download และ /download-link รับ ?format=pdf (case-insensitive; ค่าอื่น/default เป็น docx), ไม่มี PDF → 404; v9.2: ลิงก์มี Content-Disposition ชื่อ {base}_v{N}.docx (attachment) / .pdf (inline), รองรับชื่อไทยด้วย RFC 5987
GET /api/documents/{documentId}/versions/{versionId}/download        # proxy stream (curl/Swagger)
GET /api/documents/{documentId}/versions/{versionId}/download-link   # open-link อายุสั้นของ backend (add-in/เว็บใช้อันนี้)
  • download — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง
  • download-link (v9.2) — ออก open-link ของ backend เอง: URL https ของ backend + token HMAC อายุสั้น (config ได้ OpenLinks__Secret/BaseUrl/ExpirySeconds, default ~2 นาที, clamp 30 วิ–10 นาที) ฝังใน path รูป /api/open/{t}/{e}/v/{did}/{vid}/file.docx (draft: /d/{objectKey}) — ไม่มี query string เพราะ Word for Mac 16.101+ เมิน URL ที่มี query · /api/open เป็น [AllowAnonymous] โดยใช้ลายเซ็นแทน Bearer (Word/เบราว์เซอร์แนบ header ไม่ได้) แล้ว stream ไฟล์จาก MinIO ผ่าน backend (ไม่ expose MinIO เท่าเดิม) · ยังเป็นกลไกหลักของ add-in เพราะ webview ของ host ทิ้ง blob download (v3)

FR-8: Auth (Keycloak / OpenID Connect)

Keycloak setup

  • สร้าง Realm แยกสำหรับระบบนี้ (เช่น word-versioning)
  • สร้าง Client 3 ตัว:
    • word-addin-officejs — public client, Standard Flow (Authorization Code) เปิด, บังคับ PKCE (S256), ปิด client secret (เพราะรันบน browser/task pane เข้าถึงได้)
    • word-addin-vsto — public client เช่นกัน สำหรับ desktop app (ดูรายละเอียด flow ใน spec-vsto.md)
    • word-versioning-web — public client สำหรับ Web Portal, บังคับ PKCE (S256), redirect URI http://localhost:5173/* — ใช้ OIDC browser redirect ปกติ (top-level page)
  • Redirect URI ของ word-addin-officejs ต้องชี้ไปหน้า redirect ภายใน add-in เอง (เช่น https://localhost:3000/auth-callback.html ตอน dev)

Login flow ฝั่ง Office Add-in (Office.js)

Task pane รันอยู่ใน iframe — Keycloak login page ปกติส่ง header X-Frame-Options/CSP กัน embed ใน iframe อยู่แล้ว จึง ห้าม redirect iframe ตรงๆ ไป Keycloak ต้องใช้ Office Dialog API แทน:

  1. Task pane เรียก Office.context.ui.displayDialogAsync(keycloakAuthUrl, { height: 60, width: 30 }) เปิด dialog window แยก (ไม่ใช่ iframe) ไปหน้า Keycloak login (Authorization Code + PKCE)
  2. ผู้ใช้ login ใน dialog นั้น → Keycloak redirect กลับมาที่ auth-callback.html (หน้าเล็กๆ ที่รันอยู่ใน dialog)
  3. auth-callback.html เรียก Office.context.ui.messageParent(JSON.stringify({ code, state })) ส่ง authorization code กลับไปที่ task pane แล้วปิด dialog เอง (Office.context.ui.messageParent + window.close())
  4. Task pane รับ code → แลก token ที่ Keycloak token endpoint ด้วย PKCE verifier (ไม่ต้องพึ่ง backend ช่วยแลก เพราะเป็น public client)
  5. เก็บ access_token/refresh_token ไว้ใน memory (JS variable) ของ task pane session ห้ามเก็บใน localStorage (เสี่ยง XSS/ไม่หายเมื่อปิด task pane) — ถ้าต้อง persist ข้าม session ให้ใช้ sessionStorage เท่านั้นและ set อายุสั้น
  6. ก่อนหมดอายุ access token ให้ silent-refresh ด้วย refresh_token ผ่าน token endpoint

Backend (ASP.NET Core)

  • ใช้ Microsoft.AspNetCore.Authentication.JwtBearer config:
    builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            options.Authority = "http://keycloak:8080/realms/word-versioning";
            options.Audience = "word-addin-officejs"; // หรือ resource server audience ที่ตั้งไว้
            options.RequireHttpsMetadata = false; // dev เท่านั้น, prod ต้อง true
        });
    
  • Backend validate token ผ่าน Keycloak's JWKS endpoint อัตโนมัติ (ไม่ต้อง hardcode public key)
  • ทุก endpoint ใน DocumentsController ต้องมี [Authorize]
  • ดึง userId/username จาก JWT claims (sub, preferred_username) มาใช้แทนการรับจาก client ตรงๆ (กัน client ปลอมค่า CreatedByUserId)

FR-9: Template management (เพิ่มเติม v2)

  • CRUD template: upload ไฟล์ .docx template (ที่มี content controls), ตั้ง name/description/active flag
  • ตอน upload/replaces ไฟล์ ให้ backend parse content controls (SDT tags) ออกมาเป็น field list อัตโนมัติ ด้วย OpenXML
  • Admin ตั้งค่าต่อ field ผ่าน Web Portal: label, type (text/number/date), required, ลำดับ, editor (FR-14)
  • ลบ template = soft-delete (deactivate) เท่านั้น เพราะ document เก่ายังอ้างอยู่ — เอกสารที่สร้างแล้วใช้ template ที่ deactivate ต่อได้
  • ไฟล์ template เก็บใน MinIO bucket docx-templates (private)
POST /api/templates                     # upload + auto-detect fields (multipart: file + name + description)
GET /api/templates                      # รายการ (filter active/all)
PUT /api/templates/{id}                 # แก้ metadata + บันทึก field definitions
PUT /api/templates/{id}/file            # แทนที่ไฟล์ (re-detect fields, เตือนถ้า tag หายไป)
DELETE /api/templates/{id}              # deactivate (soft-delete)

FR-10: สร้างเอกสารจาก template ผ่าน Web Portal (เพิ่มเติม v2)

POST /api/documents/generate   { templateId, values: { <fieldKey>: <value> } }
  • Validate ก่อน: required fields ครบ, type ถูก (number/date parse ได้)
  • DocxGenerationService (OpenXML): โหลด template จาก MinIO → แทนที่ข้อความใน SDT แต่ละ field ที่ run level (รักษา formatting ของ template) → stamp Custom Properties (SavedByAddIn = GUID ของ add-in, DocumentTrackingId = GUID ใหม่) ลงไฟล์ — ทำฝั่ง backend เท่านั้น เพื่อให้ v0 + metadata เกิดพร้อมกันแบบ atomic ไฟล์ที่ดาวน์โหลดถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก
  • สร้าง Document record (ผูก TemplateId) + DocumentFieldValue ทุก field + อัปโหลดเป็น version 0 + snapshot fields ของ v0
  • Response { documentId, versionId, versionNumber, downloadUrl } → ผู้ใช้ดาวน์โหลด .docx ไปเปิดใน Word → FR-1 ทำงานทันที (เป็น tracked document เห็น v0 โดยไม่ต้องกด Start Versioning)

FR-11: Field sync — update fields ใน DB ตอนสร้าง version (เพิ่มเติม v2)

  • ขยาย POST /api/documents/{documentId}/versions ให้รับ fields (map tag→text ที่ add-in extract มาตาม FR-2 ขั้นตอนที่ 6)
  • เทียบกับ TemplateField ของ template ที่ document ผูกอยู่ → update latest value ต่อ field (DocumentFieldValue) + เก็บ snapshot ต่อ version (DocumentVersion.FieldsSnapshotJson — ใช้ diff ใน history ได้)
  • field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → เก็บค่าเดิม + mark missing ใน snapshot แต่ไม่ fail request; ส่งมาเกินนิยาม → ignore
  • UpdatedByUserId มาจาก JWT claims ของผู้กด Save & Version เสมอ

FR-12: รายการเอกสารต่อ template + Document detail + Re-generate (เพิ่มเติม v4)

  • GET /api/templates/{templateId}/documents — รายการเอกสารที่สร้างจาก template หนึ่งตัว (ชื่อไฟล์, จำนวน version, version ล่าสุด + เวลา) เรียงตามกิจกรรมล่าสุด — ใช้ได้แม้ template ถูก deactivate (เอกสารเก่ายังต้องเข้าถึงได้ตาม FR-9)
  • GET /api/documents/{documentId} — detail ของเอกสาร: ข้อมูลหัวเรื่อง + template + ค่าล่าสุดของทุก field (นิยามจาก TemplateField merge กับ DocumentFieldValue) — รองรับ template เปลี่ยนนิยามหลังเอกสารสร้างแล้ว: field ที่เพิ่มเข้าทีหลังแสดงค่าว่าง, ค่าที่ key ไม่มีในนิยามปัจจุบันยังแสดงโดยไม่มี label
  • หน้าเว็บ (Web Portal): หน้ารายการเอกสารของ template + หน้า detail ที่แสดงค่า fields ปัจจุบันและ version history (ขยายแต่ละ version เห็น snapshot ค่าตอนนั้น + ดาวน์โหลด)
  • Re-generate: จากหน้า detail ของเอกสารที่สร้างจาก template → เข้าฟอร์มสร้างเอกสารโดย prefill ค่าล่าสุดของทุก field ให้แก้ไขก่อน → สร้างเป็น เอกสารใหม่ (v0 ใหม่ + documentId ใหม่) ผ่าน endpoint generate เดิม — เอกสารต้นทางและ version history ของมันไม่ถูกแตะ (ไม่เกิด version ใหม่) — template ที่ deactivate จะ re-generate ไม่ได้ตามกติกาของ FR-10

FR-13: คำอธิบายต่อ version — commit message (เพิ่มเติม v5)

  • Field ชื่อ message — string ทางเลือก (null ได้) ยาวไม่เกิน 2048 อักขระ เก็บที่ DocumentVersion.Message — ผู้ใช้พิมพ์อธิบายการเปลี่ยนแปลงของ version นั้นแบบเดียวกับ git commit message
  • รับจากทุก flow ที่สร้าง version:
    • POST /api/documents/{documentId}/versions — multipart form field message (เพิ่มจาก file/clientVersionId/fields เดิม)
    • POST /api/documents/generate — JSON property message (เพิ่มจาก templateId/values เดิม)
  • ค่าว่าง/whitespace ล้วน → เก็บ null (trim ก่อนเก็บ — นโยบายเดียวกับ Template.Description) · เกิน 2048 อักขระ → 400
  • แสดงใน version history ทุกที่: ตาราง + การ์ดมือถือของ Web Portal (FR-12) และ VersionHistoryPanel ของ add-in (FR-4)
  • Add-in: dialog ยืนยันมี textarea ทางเลือก — ทั้ง Save & Version (FR-2) และ Start Versioning (FR-1 ซึ่ง v5 นี้เพิ่ม dialog ยืนยันให้ flow เริ่ม track ก่อนอัปโหลด v1)
  • Web Portal: ฟอร์มสร้างเอกสาร (FR-10) มี textarea ทางเลือก — re-generate ตาม FR-12 ไม่ prefill ข้อความ (เป็นเอกสารใหม่ ได้ข้อความใหม่)
  • ไม่ฝังลงไฟล์ .docx (custom properties หรือ custom XML part) — backend คือ source of truth ของ version history ตามกติกา และเลี่ยงปัญหา backward-compat กับไฟล์เก่า

FR-14: Field editor — รูปแบบช่องกรอกต่อ field (เพิ่มเติม v6)

  • ผู้ดูแลเลือก editor (รูปแบบ control ในฟอร์มสร้างเอกสารของ Web Portal) ต่อ field ได้ โดยเป็น metadata ที่แยกจาก Type (ซึ่งคุม validation):
    • text → textbox (default) หรือ textarea — สำหรับข้อความหลายบรรทัด
    • number → number (ช่องตัวเลข — ทางเดียว)
    • date → date (date picker — ทางเดียว)
  • PUT /api/templates/{id}: editor เป็นทางเลือก — ไม่ส่ง/ว่าง = default ของ type · ค่าที่ไม่รู้จักหรือไม่เข้ากับ type → 400 · เก็บค่าที่เท่ากับ default เป็น null (normalize-on-write) แต่ API คืนค่า resolved เสมอ
  • field ที่ detect อัตโนมัติตอน upload/replace ได้ editor = default ของ type (textbox)
  • presentation metadata ล้วน — ไม่กระทบ validation หรือ generate (validation ยึด Type เท่านั้น) ค่า field เป็น string ทั้ง flow ไม่แตะ DocumentFieldValue/FieldsSnapshotJson/ไฟล์ docx/add-in

FR-15: Version PDF — สำเนา PDF ที่ Word ผลิต (เพิ่มเติม v7)

  • Word เป็นผู้ผลิต PDF เท่านั้น: add-in ใช้ getFileAsync(Office.FileType.Pdf) ทั้ง Save & Version และ Start Versioning; runtime guard PdfFile 1.1, ไม่บังคับใน manifest
  • เว็บ generate/re-generate เป็น v0 ฉบับตั้งต้น ไม่มี PDF; add-in เริ่ม v1 แล้ว max+1; ไม่ backfill หรือเปลี่ยนเลขข้อมูลเก่า
  • capture docx แล้ว PDF ตามลำดับ ปิด File object เสมอ; PDF capture ไม่รองรับ/ล้ม/ว่าง → ข้าม PDF แล้วบันทึก docx ต่อ
  • multipart pdf ทางเลือก: ต้องไม่ว่าง, นามสกุล .pdf, content-type application/pdf หรือ octet-stream หรือว่าง; ไม่ถูกต้อง → 400 ก่อน DB/storage, เกินขนาด → 413; request cap 100MB รวม docx+pdf
  • เก็บ PDF ใน bucket docx-versions, sibling key {documentId}/v{N:D4}_{clientVersionId}.pdf; PdfObjectKey nullable
  • docx upload ล้มใช้ error เดิม; PDF upload ล้มเป็น best-effort → version สำเร็จ hasPdf:false; task pane เตือน “บันทึกแล้ว แต่สร้าง PDF ไม่สำเร็จ” และแนะนำ Save & Version ใหม่
  • upload/history response มี hasPdf; ปุ่ม PDF แสดงเฉพาะเมื่อมี PDF; add-in เปิด presigned URL ผ่าน openBrowserWindow
  • เว็บ history ทั้ง desktop/mobile เปิด docx แบบ download-first (v9.2): กดปุ่ม → ดาวน์โหลดไฟล์ทันทีทุกครั้ง (ลิงก์เปิดของ backend, disposition attachment) + ยิง ms-word:ofe|u|<url> ควบคู่แบบ best-effort เฉพาะ non-Mac โดย default — บน Mac ไม่ยิงเพราะ Word 16.101+ ไม่เพียงเมิน URL แต่เด้งหน้าเลือกไฟล์ทุกครั้งจนรำคาญ (override ได้ด้วย env NUXT_PUBLIC_OPEN_IN_WORD=on|off) + ข้อความบอกว่าไฟล์อยู่ Downloads; PDF เปิดแท็บใหม่ · เลิกใช้ heuristic การสลับแอป (v9.1) เพราะโดนหลอกจนไม่ดาวน์โหลด (ยืนยันด้วยการทดสอบบนเครื่องจริง 2026-09-10)
  • หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง

FR-16: ไฟล์ร่าง (draft) — สร้าง .docx จาก template โดยไม่สร้าง Document (เพิ่มเติม v8 · ปรับ v9)

  • POST /api/templates/{templateId}/generate-draft body { values } — ไม่สร้าง Document/DocumentVersion/DocumentFieldValue ใน DB; generate ไฟล์แล้วเก็บชั่วคราวใน bucket docx-versions ใต้ prefix drafts/{templateId}/{yyyyMMddHHmmss}_{guid}.docx ซึ่ง lifecycle rule ลบให้เอง (default 1 วัน — config Minio__DraftRetentionDays, backend ตั้ง rule ตอน startup แบบ idempotent/best-effort) แล้วคืน 200 { url, expiresAt } เป็น open-link อายุสั้นของ backend (v9.2 — ดู FR-7; attachment + ชื่อไฟล์ไทย RFC 5987 ตอน stream) ให้เว็บดาวน์โหลด/เปิดต่อ (v8 เดิมคืนไฟล์เป็น stream ตรงๆ; v9.0–9.1 เคยเป็น presigned ของ MinIO ซึ่งเป็น http จึงเอาออก)
  • validate เหมือน FR-10 (required + type + format; ส่งเกินนิยาม ignore) · template ไม่พบ → 404, deactivate → 400 ตามนโยบาย FR-10 · ไม่มี message (ไม่มี version จึงไม่มี commit message) · ค่าที่กรอกจะถูกตัดอักขระที่ XML 1.0 ห้ามออกก่อนเขียนลงไฟล์ (0x00–0x1F ยกเว้น tab/LF/CR, lone surrogate, 0xFFFE/0xFFFF — มักติดมากับการ paste เช่น vertical tab 0x0B ซึ่งทำ XmlWriter โยนตอน save) ใช้ร่วมทั้ง generate และ generate-draft · OpenXML โยน exception ที่ไม่ได้ตั้งใจ → 502 พร้อมข้อความอ่านออก ไม่ปล่อย 500 stack
  • stamp custom property TemplateId = GUID ของ template ตัวเดียว — ไม่ stamp SavedByAddIn/DocumentTrackingId ไฟล์ร่างจึงเป็น untracked ในสายตา add-in (เลี่ยงสถานะ "tracked แต่ history 404")
  • ค่าที่แทนที่เขียนที่ run ของ "ข้อความ placeholder จริง" (v9 — ใช้ร่วมทั้ง generate และ generate-draft): template ไทยมักแตก placeholder เป็น [ / ข้อความ / ] หลาย run โดย run ข้อความถือ w:hint="cs" + w:cs (complex script) ที่ Word ใช้ตัดคำ/กระจายภาษาไทย — เขียนค่าที่ run ที่มีข้อความยาวสุด (ไม่ใช่ run แรกที่เป็นปีกกา) · SDT เปล่าให้ run ใหม่สืบทอด rPr จาก sdtPr (เหมือนพฤติกรรม Word ตอนพิมพ์ลง control)
  • ผูก template ตอน Start Versioning: POST /api/documents/{documentId}/versions รับ templateId (multipart form field ทางเลือก) — ใช้เฉพาะตอน auto-create Document (field sync ทำงานเต็มระบบที่ v1); document มีอยู่แล้ว → เฉย (แต่ validate ทุกครั้งที่ส่งมา) · ไม่ใช่ GUID / ไม่พบใน DB → 400 · template ที่ deactivate → ผูกได้ (ไฟล์ร่างถูกสร้างตอน template ยัง active — นโยบายเดียวกับ FR-9/FR-12) · upload แรกของ Document ใหม่ = version 1 (max+1 — ไม่มี v0)
  • Add-in: อ่าน property TemplateId ตอน untracked แล้วแนบ templateId เฉพาะตอน Start Versioning (Save & Version ไม่ส่ง) + แสดง hint ว่าไฟล์นี้เป็นไฟล์ร่างจาก template
  • Web Portal: ปุ่ม "สร้างไฟล์ร่าง" ที่สองบนฟอร์มสร้างเอกสารเดิม → download-first เหมือน flow เปิด v0 (v9.2 — ดาวน์โหลดทันที + ยิง ms-word ควบคู่, กลไกเดียวกันผ่าน useVersionOpen); ฟอร์มอยู่ต่อ ไม่มี documentId/หน้าผลลัพธ์ · ช่องคำอธิบาย version ใช้กับ flow สร้างเอกสาร v0 เท่านั้น · flow v0 (FR-10) และ re-generate (FR-12) คงเดิม

7. Data Model (Backend)

public class Document
{
    public Guid Id { get; set; }
    public string FileName { get; set; }
    public string AddInSignature { get; set; }
    public Guid? TemplateId { get; set; }        // เอกสารที่สร้างจาก template (FR-10) — null ถ้า track เอกสารเปล่า
    public List<DocumentVersion> Versions { get; set; } = new();
}

public class DocumentVersion
{
    public Guid Id { get; set; }
    public Guid DocumentId { get; set; }
    public int VersionNumber { get; set; } // generate=0; add-in=max+1 เริ่ม 1
    public string ObjectKey { get; set; }       // MinIO object key (bucket/path)
    public string? PdfObjectKey { get; set; }   // FR-15: null = ไม่มี PDF
    public string ContentHash { get; set; }
    public string CreatedByUserId { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    public string? FieldsSnapshotJson { get; set; }  // snapshot ค่า fields ของ version นี้ (FR-11)
    public string? Message { get; set; }             // คำอธิบาย version จากผู้ใช้ แบบ commit message (FR-13) — ยาวสูงสุด 2048
}

public class Template                              // เพิ่มเติม v2 (FR-9)
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string? Description { get; set; }
    public string ObjectKey { get; set; }         // ไฟล์ .docx ใน bucket docx-templates
    public bool IsActive { get; set; } = true;    // soft-delete ผ่าน flag นี้
    public DateTimeOffset CreatedAt { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
    public List<TemplateField> Fields { get; set; } = new();
}

public class TemplateField
{
    public Guid Id { get; set; }
    public Guid TemplateId { get; set; }
    public string Key { get; set; }               // = content control (SDT) tag — unique ต่อ template
    public string Label { get; set; }             // ชื่อที่แสดงใน form
    public string Type { get; set; }              // text | number | date
    public string? Editor { get; set; }           // รูปแบบช่องกรอก (FR-14): textbox|textarea สำหรับ text · number · date — null = default ของ Type
    public bool IsRequired { get; set; }
    public int SortOrder { get; set; }
}

public class DocumentFieldValue                    // latest value ต่อ field ของ document (FR-11)
{
    public Guid DocumentId { get; set; }
    public string FieldKey { get; set; }          // = TemplateField.Key
    public string Value { get; set; }
    public Guid SourceVersionId { get; set; }     // version ล่าสุดที่อัปเดตค่านี้
    public string UpdatedByUserId { get; set; }
    public DateTimeOffset UpdatedAt { get; set; }
}

8. Data Model (Frontend / metadata ในไฟล์ docx)

Custom Document Properties:

Key Value
SavedByAddIn GUID คงที่ของ add-in — ค่าที่ปักหมุด: 9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D (backend: Services/AddInMetadata.AddInSignatureGuid, add-in: ADDIN_SIGNATURE_GUID ต้องเป็นค่านี้เป๊ะ)
DocumentTrackingId GUID เฉพาะของเอกสาร (ผูกกับ documentId ฝั่ง backend)
LastVersionId GUID ของ version ล่าสุด — คือ clientVersionId ที่ client generate (ต้อง stamp ลงไฟล์ก่อน save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง)
LastVersionTimestamp ISO-8601 UTC timestamp
TemplateId GUID ของ template ต้นทาง — มีเฉพาะไฟล์ร่าง (FR-16); backend stamp ตัวเดียวตอน generate-draft, add-in อ่านตอน untracked เพื่อแนบ templateId ตอน Start Versioning (เมื่อเริ่ม track แล้วค่านี้ยังอยู่ในไฟล์แต่ไม่ถูกใช้)

Custom XML Part (namespace urn:myaddin:versioning) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):

<VersionHistory xmlns="urn:myaddin:versioning">
  <Version id="..." number="3" timestamp="..." user="..."/>
  <Version id="..." number="2" timestamp="..." user="..."/>
</VersionHistory>

9. Non-Functional Requirements

  • Manifest ต้อง sideload ได้บน Word Desktop (Win/Mac) และ Word Online เพื่อ dev/test ได้ทั้งสามแพลตฟอร์ม
  • Backend endpoint upload ต้อง handle ไฟล์ขนาดใหญ่ (จำกัด request size ให้เหมาะสม เช่น 100MB) และ reject content-type ที่ไม่ใช่ docx
  • ทุก error จาก Office.js call ต้อง catch และแสดงข้อความที่เข้าใจง่ายใน task pane (อย่าปล่อย unhandled promise rejection)
  • Backend ต้องมี CORS config อนุญาต origin ของ add-in (https://localhost:3000 สำหรับ dev)
  • Logging ฝั่ง backend: log ทุก upload/download พร้อม documentId, versionId, userId
  • MinIO bucket ต้องสร้างแบบ private (ไม่ public read) — เข้าถึงไฟล์ได้ผ่าน backend API เท่านั้น (proxy stream หรือ presigned URL ที่หมดอายุสั้นๆ)
  • Config การเชื่อมต่อ MinIO (Endpoint, AccessKey, SecretKey, BucketName, UseSSL) ต้องแยกเป็น environment variable/appsettings.json ไม่ hardcode ในโค้ด เพื่อ deploy ไป production MinIO cluster ได้โดยไม่แก้โค้ด
  • Config Keycloak (Authority, Audience, ClientId ของแต่ละ client) ต้องแยกเป็น environment variable/appsettings.json เช่นกัน ไม่ hardcode
  • ถ้า access token หมดอายุระหว่างใช้งาน task pane ต้อง silent-refresh อัตโนมัติ ถ้า refresh token หมดอายุ/ถูก revoke ให้ redirect กลับไป login flow ใหม่แบบ graceful (ไม่ใช่ error ค้าง)

เพิ่มเติม v2 (Template System & Web Portal)

  • Field key = content control (SDT) tag ต้อง unique ต่อ template — ใช้ tag เดียวกันทั้งตอน generate (backend), ตอน extract (add-in) และตอน sync (DB)
  • การ generate + stamp metadata ต้องทำฝั่ง backend เท่านั้น (OpenXML) — ไฟล์ที่ดาวน์โหลดจากเว็บต้องถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก ไม่พึ่ง client ตั้งค่าใดๆ
  • OpenXML replace ต้องแก้ที่ run level ใน SDT เพื่อรักษา formatting ของ template — ห้ามทับทั้ง paragraph
  • Web Portal ทุกหน้ารองรับ light/dark theme (toggle + persist + default ตาม prefers-color-scheme)
  • Dev server ports ห้ามชนกัน: add-in = https://localhost:3000, Web Portal = http://localhost:5173 (ตั้งใน nuxt.config.ts)
  • Dev ต้องเสิร์ฟ HTTPS ด้วย cert ของ office-addin-dev-certs ทุก service ที่ task pane เรียก (v3): Keycloak = https://localhost:8443 (dialog บังคับ HTTPS), backend = https://localhost:7232 เพิ่มคู่กับ http://localhost:5075 (กัน mixed content) — ต้องรัน npx office-addin-dev-certs install ก่อน docker compose up เพราะ Keycloak mount cert จาก ~/.office-addin-dev-certs
  • Backend CORS ต้องอนุญาตทั้ง origin ของ add-in (https://localhost:3000) และ Web Portal (http://localhost:5173)

10. Acceptance Criteria

  1. Sideload add-in บน Word Desktop และ Word Online ได้ทั้งคู่ ไม่มี error ตอนโหลด task pane
  2. เปิด task pane ครั้งแรกโดยยังไม่ login → เห็นปุ่ม "Sign in", กดแล้วเปิด dialog ไป Keycloak login page ได้ถูกต้อง, login สำเร็จแล้ว dialog ปิดเองและ task pane ได้ token มาใช้งาน
  3. เรียก backend endpoint โดยไม่แนบ token → ได้ 401 Unauthorized
  4. เปิดไฟล์ .docx ใหม่ที่ไม่เคย track (หลัง login แล้ว) → task pane แสดงปุ่ม "Start Versioning This Document"
  5. กด "Start Versioning" → stamp metadata ครั้งแรก, สร้าง Document record ใน backend, อัปโหลด version 1 สำเร็จ, CreatedByUserId ใน DB ตรงกับ sub/preferred_username จาก JWT ของผู้ login
  6. ปิดแล้วเปิดไฟล์เดิมใหม่ (ทั้งบน Windows และ Mac) → task pane ตรวจพบว่าเป็น tracked document และโหลด version history จาก backend ได้ถูกต้อง
  7. แก้ไขเอกสารแล้วกด "Save & Version" → มี version ใหม่เพิ่มใน backend, LastVersionId/LastVersionTimestamp ใน properties อัปเดตถูกต้อง
  8. ดาวน์โหลด version เก่าจาก panel ได้ไฟล์ที่เปิดด้วย Word ได้ปกติ และเนื้อหาตรงกับตอน upload
  9. ปล่อย session ทิ้งไว้จนกว่า access token หมดอายุ แล้วลองใช้งานต่อ → ต้อง silent-refresh สำเร็จโดยผู้ใช้ไม่รู้สึกสะดุด (ไม่ต้อง login ใหม่)
  10. ทดสอบบน Word for Mac: Custom XML Part อาจ fail แบบ silent ตามที่ระบุใน spec — ต้องมี fallback ที่ระบบยังทำงานถูกต้องแม้ XML part เขียนไม่สำเร็จ (เพราะ backend คือ source of truth)
  11. (v2) upload template .docx ที่มี content controls ผ่านหน้าเว็บ → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้
  12. (v2) กรอก form บนเว็บสร้างเอกสาร → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง formatting ไม่พัง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 0 ทันที โดยไม่ต้องกด Start Versioning
  13. (v2) แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
  14. (v2) ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
  15. (v4) เข้าหน้ารายการเอกสารของ template บนเว็บ → เห็นเอกสารที่สร้างจาก template นั้นครบ พร้อมจำนวน version และเวลาล่าสุด · เข้าหน้า detail → เห็นค่าทุก field ปัจจุบัน + version history (ขยายเห็นค่าต่อ version, ดาวน์โหลดได้) · กด re-generate แล้วสร้างเอกสารใหม่เป็น v0 โดยเอกสารต้นทางไม่มี version ใหม่
  16. (v5) พิมพ์คำอธิบายตอนสร้าง version ได้จากทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า (web ตาราง + การ์ดมือถือ, add-in panel) · เว้นว่างได้ (ไม่ส่ง/ช่องว่างล้วน = ไม่มีข้อความ ไม่ error)
  17. (v6) admin เลือก editor ของ field type text เป็น textarea ได้ผ่านหน้าจัดการ template → ฟอร์มสร้างเอกสารแสดงเป็น textarea · field number/date ใช้ control ตาม type (ช่องตัวเลข / date picker) · ส่ง editor ที่ไม่รู้จักหรือไม่เข้ากับ type → 400 · ไม่ส่ง editor → ได้ default ของ type · ค่า field และการ generate ไม่เปลี่ยนจากเดิม

  1. (v7, AC-18) generate ได้ v0 ไม่มี PDF → Save & Version ได้ v1 พร้อม PDF; Start Versioning ได้ v1 พร้อม PDF; PDF capture/storage ล้มยังบันทึก docx สำเร็จพร้อมคำเตือน; history แสดง PDF เฉพาะที่มี; download/proxy/presigned PDF ถูก bytes/type/ชื่อ และไม่มี → 404; เว็บเปิด Word/PDF พร้อม fallback; ตรวจ fidelity/markup บน Word จริง
  2. (v8, AC-19 · ปรับ v9) สร้างไฟล์ร่างจากฟอร์มเว็บ → เปิดใน Word ทันที (ms-word:ofe + ลิงก์สำรองอายุสั้น) ได้ .docx ค่าครบ จัดหน้า/ตัดคำไทยถูกต้องตาม template โดยไม่เกิด Document/version ใน DB (object ใต้ drafts/ หมดอายุรายวัน) → add-in แสดง untracked + hint ไฟล์ร่าง → Start Versioning → Document + template + v1 + fields sync ครบ → templateId ไม่ใช่ GUID/ไม่พบ → 400 · deactivate ผูกได้ · Save & Version ถัดไป v2, v3… · flow v0 (FR-10) ยังทำงานคู่กัน

11. งานที่ Claude Code ควรทำ

Frontend

  1. Scaffold Office Add-in project ด้วย yo office (task pane, TypeScript, React)
  2. Implement metadataService.ts, fileService.ts, versioningService.ts, authService.ts, apiClient.ts — metadataService รวมการอ่าน content controls เป็น map {tag: text} สำหรับ field sync (FR-11)
  3. authService.ts ต้อง implement: build Keycloak authorization URL + PKCE code_verifier/code_challenge, เปิด Office.context.ui.displayDialogAsync, รับ code กลับผ่าน messageParent, แลก token, เก็บ token in-memory, silent refresh
  4. Implement VersionHistoryPanel.tsx, ConfirmVersionDialog.tsx, และหน้า sign-in state
  5. เขียน auth-callback.html (หน้าเล็กๆ รับ redirect จาก Keycloak แล้ว messageParent กลับ)
  6. เขียน manifest.xml ให้ครบ requirement sets ที่ใช้
  7. เขียน unit test สำหรับ service layer (mock Word.run, fetch, และ mock Keycloak token response)
  8. (v5) ConfirmVersionDialog เพิ่ม textarea ทางเลือก (commit message) + ให้ Start Versioning ใช้ dialog ด้วย + ส่ง message ผ่าน versionFlow/versioningService + แสดงใน VersionHistoryPanel (FR-13)

Backend

  1. Scaffold ASP.NET Core 8 Web API project
  2. Implement AppDbContext, migrations
  3. Config JWT Bearer authentication ให้ validate ผ่าน Keycloak Authority/JWKS ตาม FR-8
  4. Implement DocumentsController ตาม FR-5 ถึง FR-7 พร้อม [Authorize] ทุก endpoint, ดึง userId จาก JWT claims
  5. Implement ObjectStorageService (interface IObjectStorageService) เชื่อมต่อ MinIO ผ่าน AWSSDK.S3 — ตั้ง ServiceURL ชี้ไป MinIO endpoint, ForcePathStyle = true (จำเป็นสำหรับ MinIO)
  6. เขียน realm-export.json สำหรับ Keycloak local dev — pre-config realm word-versioning, client word-addin-officejs (public, PKCE) และ word-addin-vsto (public, PKCE)
  7. เขียน integration test สำหรับ upload/download version flow (รัน MinIO + Keycloak จริงผ่าน testcontainers หรือ docker-compose ตอนเทส, ใช้ Keycloak admin API สร้าง test user + ขอ token มาแนบ request)
  8. เขียน docker-compose.yml สำหรับ local dev: MinIO + Keycloak (import realm-export.json อัตโนมัติ) + DB container + backend
  9. เขียน README อธิบาย local dev setup (sideload add-in, run backend, connection string, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก)
  10. (v2) Implement TemplatesController + entities Template/TemplateField/DocumentFieldValue + migrations (FR-9) รวม parse SDT tags ตอน upload
  11. (v2) Implement DocxGenerationService + POST /api/documents/generate (FR-10) — แทนที่ SDT ที่ run level + stamp custom properties + สร้าง Document + v0 + field values ใน flow เดียว
  12. (v2) ขยาย upload endpoint ให้รับ fields แล้ว sync DocumentFieldValue + snapshot ต่อ version ตาม missing-field policy (FR-11)
  13. (v2) เขียน integration test ครอบ template detect / generate / field sync (รวมกรณี field หาย → ค่าเดิม + mark missing)
  14. (v5) เพิ่ม DocumentVersion.Message + migration AddVersionMessage + รับ/คืน message ทั้ง upload/generate/history + ตรวจความยาว 2048 (FR-13)
  15. (v6) เพิ่ม TemplateField.Editor + migration AddTemplateFieldEditor + validate editor ตาม type ใน PUT /api/templates + คืน editor ใน TemplateFieldDto (FR-14)

Web Portal (เพิ่มเติม v2)

  1. Scaffold Nuxt.js + TailwindCSS — dev port 5173, darkMode: 'class', useTheme (light/dark toggle + persist + system default)
  2. useAuth (PKCE browser redirect ผ่าน Keycloak client word-versioning-web) + useApi (Bearer + silent refresh)
  3. หน้าจัดการ template: list / upload (โชว์ fields ที่ detect อัตโนมัติ) / แก้ field definitions / deactivate
  4. หน้าสร้างเอกสาร: dynamic form สร้างจาก field definitions (input type ตาม field type) + validate + ดาวน์โหลดผลลัพธ์
  5. เขียน unit test ของ form validation + useTheme (Vitest)
  6. (v4/FR-12) หน้ารายการเอกสารของ template + หน้า detail ของเอกสาร (ค่า fields ปัจจุบัน + version history พร้อม snapshot ต่อ version + ดาวน์โหลดต่อ version) + re-generate ผ่านฟอร์ม prefilled ค่าปัจจุบัน
  7. (v5) ฟอร์มสร้างเอกสารเพิ่ม textarea ทางเลือก (commit message) + แสดง message ใน version history (FR-13)
  8. (v6) หน้าแก้ field definitions เพิ่มตัวเลือก editor ต่อ field (เลือกได้เฉพาะ type text) + ฟอร์มสร้างเอกสาร render ตาม editor (textarea/ช่องตัวเลข/date picker/textbox) + util fieldEditor (FR-14)

เพิ่มเติม v7 (FR-15)

  • Add-in: PDF capture + multipart + warning + history PDF
  • Backend: migration PdfObjectKey + validation/storage/download + hasPdf + generate v0
  • Web: เปิด Word/PDF + fallback + history v0/v1+

เพิ่มเติม v8 (FR-16)

  • Backend: POST /api/templates/{id}/generate-draft (ตอน v8 คืนไฟล์ตรงๆ — ปรับเป็นเก็บชั่วคราว + presigned URL ใน v9) + DocxGenerationService.GenerateDraft (stamp เฉพาะ TemplateId) + แยก TemplateFieldValidation ใช้ร่วมกับ generate + form field templateId ทางเลือกของ upload (ใช้ตอน auto-create)
  • Add-in: อ่าน TemplateId ใน readTrackedProperties + ส่ง templateId เฉพาะ startVersioning + hint ไฟล์ร่างใน task pane
  • Web: ปุ่ม "สร้างไฟล์ร่าง" บนฟอร์มเดิม
  • Backend: OpenLinkService (HMAC อายุสั้น OpenLinks__*) + OpenController /api/open/{t}/{e}/v/{did}/{vid}/file.{docx|pdf} และ /d/{objectKey} — ไม่มี query string, [AllowAnonymous] ด้วยลายเซ็น, stream จาก MinIO · download-link และ generate-draft ออก open-link แทน presigned ของ MinIO
  • Web: openDocxUrl = ดาวน์โหลดทันที (anchor → attachment) + ยิง ms-word:ofe ควบคู่ + notice "ไฟล์อยู่ Downloads" — เอา heuristic blur/visibilitychange ออก
  • การทดสอบบนเครื่องจริง (Mac, Word 16.101.25092124, 2026-09-10): ms-word:ofe + http → เมิน; + https (มี query หรือไม่มี) → เมิน (Word เด้งหน้าเลือกไฟล์ ไม่ยิง request ถึง server เลย); + file:// → เมิน; open -a Word <local> → เปิดได้ (แต่ใช้จากเว็บไม่ได้เพราะ browser sandbox และ backend รันบน server กลางใน production) → เหลือ download-first เป็นทางเดียวที่ deterministic
  • ทางเลือกอนาคต (ยังไม่ทำ): helper เล็กติดตั้งบนเครื่อง user รับคำสั่งผ่าน localhost แล้วดาวน์โหลดไป tmp + open -a Word — ได้ปุ่มเดียวเปิดอัตโนมัติจริง แต่ต้องมี installer/security/update (scope ใหม่)

เพิ่มเติม v9 (ปรับ FR-16 ตามผลใช้งานจริง)

  • Backend: generate-draft เก็บ object ใต้ drafts/ + คืน { url, expiresAt } (presigned) + EnsurePrefixExpiryAsync ตั้ง lifecycle ตอน startup (idempotent/best-effort) + ReplaceSdtContent เขียนค่าที่ run ข้อความยาวสุด (complex script ไทย) + SDT เปล่าสืบทอด sdtPr rPr
  • Web: useVersionOpen.openDocxUrl — แยกแกนเปิด docx URL ออกจาก openVersion ให้ไฟล์ร่างใช้ร่วม (ถอด CORS expose และ downloadFile init ที่เลิกใช้)