# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend) > ฉบับแก้ไข 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 ทุก 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; presigned URL มี 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 # presigned URL อายุสั้น (add-in ใช้อันนี้) ``` - `download` — stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง - `download-link` — ออก **presigned URL อายุสั้น** (config ได้, default ~2 นาที, clamp 30 วิ–10 นาที) ให้ client เปิดในเบราว์เซอร์ของเครื่อง — เป็น**กลไกหลักของ add-in** เพราะ webview ของ host ทิ้ง blob download แบบเงียบๆ (v3) · เข้าถึง MinIO โดยตรงโดยไม่ต้องแนบ token แต่หมดอายุทันทีเมื่อเกินเวลา ### 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: ```csharp 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: { : } } ``` - 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 ผ่าน `ms-word:ofe|u|` (ไม่ encode URL ทั้งก้อนซ้ำตาม Microsoft Office URI Schemes); PDF แท็บใหม่; มีลิงก์ดาวน์โหลดสำรองและ heuristic blur/visibilitychange ~1.5s ซึ่งไม่ใช่หลักฐานว่า Word เปิดสำเร็จ - หน้า generate success เปิด v0 ใน Word พร้อมลิงก์ดาวน์โหลดสำรอง; PDF fidelity และ tracked changes ต้องยืนยันบน Word จริง ## 7. Data Model (Backend) ```csharp 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 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 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 | Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth): ```xml ``` --- ## 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 ไม่เปลี่ยนจากเดิม --- 18. (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 จริง ## 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+