eCourt-Doc-Edit/specs/spec-officejs.md
Suphonchai Phoonsawat 9bb4a48c00 Phase 10: field editor (FR-14)
เพิ่ม editor ต่อ template field — ผู้ดูแลเลือกรูปแบบช่องกรอกในฟอร์มสร้างเอกสารได้:
text → textbox/textarea, number/date → control ตาม type (presentation metadata
ล้วน — validation ยังยึด Type, ค่า field เป็น string ทั้ง flow, ไม่แตะ generate/sync/add-in)

- Backend: TemplateField.Editor (nullable, null = default ของ type) +
  migration AddTemplateFieldEditor + validate ตาม type ใน PUT templates
  (ค่าไม่รู้จัก/ไม่เข้ากับ type → 400, normalize-on-write, API คืน resolved เสมอ)
- Web: util fieldEditor.ts + editor select หน้าแก้ template (desktop+mobile,
  ซ่อนเมื่อ type มีทางเดียว) + ฟอร์มสร้างเอกสาร render ตาม editor +
  แก้ prefill re-generate ของ field date จาก ISO timestamp เต็ม (input[type=date]
  รับเฉพาะ yyyy-MM-dd เดิมแสดงว่าง)
- Docs: spec v6 (FR-14/AC-17) + plan Phase 10 + sub-plan field-editor.md +
  acceptance-review AC-17 + README/CLAUDE.md
- Tests: backend 60/60 (FieldEditorTests ใหม่ 8 กรณี) · web 46/46 + build ผ่าน

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-22 09:17:15 +07:00

443 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
> ฉบับแก้ไข 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 1 อัตโนมัติ**
ความสามารถหลัก (เทียบเท่า 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 1 อัตโนมัติ** → เปิดใน 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
```
POST /api/documents/{documentId}/versions
Content-Type: multipart/form-data
Body: file (docx binary), clientVersionId (guid)
```
- ถ้า `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 }`
### FR-6: ดึง version history
```
GET /api/documents/{documentId}/versions
```
- คืน list ของ `DocumentVersion` เรียงจากใหม่ไปเก่า
### FR-7: ดาวน์โหลด version ที่ระบุ
```
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: { <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 เท่านั้น เพื่อให้ v1 + metadata เกิดพร้อมกันแบบ atomic ไฟล์ที่ดาวน์โหลดถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก
- สร้าง `Document` record (ผูก `TemplateId`) + `DocumentFieldValue` ทุก field + **อัปโหลดเป็น version 1** + snapshot fields ของ v1
- Response `{ documentId, versionId, versionNumber, downloadUrl }` → ผู้ใช้ดาวน์โหลด .docx ไปเปิดใน Word → **FR-1 ทำงานทันที** (เป็น tracked document เห็น v1 โดยไม่ต้องกด 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** ให้แก้ไขก่อน → สร้างเป็น **เอกสารใหม่ (v1 ใหม่ + 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
---
## 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<DocumentVersion> Versions { get; set; } = new();
}
public class DocumentVersion
{
public Guid Id { get; set; }
public Guid DocumentId { get; set; }
public int VersionNumber { get; set; }
public string ObjectKey { get; set; } // MinIO object key (bucket/path)
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 |
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
```xml
<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 1 ทันที โดยไม่ต้องกด 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 แล้วสร้างเอกสารใหม่เป็น v1 โดย**เอกสารต้นทางไม่มี 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 ไม่เปลี่ยนจากเดิม
---
## 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 + v1 + 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)