implement phase 4

This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 21:35:07 +07:00
parent 25c2ce53cd
commit f4b1c3e14b
27 changed files with 6929 additions and 48 deletions

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: Phase 0–3 เสร็จ (scaffold + infra + backend core + backend template & generation) — ต่อไป: phase 4–5 (add-in) หรือ phase 6 (web) ทำขนานกันได้
> สถานะ: Phase 0–4 เสร็จ (scaffold + infra + backend ครบ + add-in services layer) — ต่อไป: phase 5 (add-in UI) หรือ phase 6 (web) ทำขนานกันได้
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
---
@ -293,31 +293,48 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** logic ทั้งหมดที่ UI จะเรียกใช้ + unit test — ตรง spec ส่วน 11 (frontend ข้อ 2, 3, 5, 6)
### 4.1 Auth (`authService.ts`, `auth-callback.html`)
- [ ] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
- [ ] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
- [ ] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
- [ ] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
- [ ] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
- [ ] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
- [ ] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
- [ ] ป้องกัน CSRF ด้วยการเทียบ `state`
- [x] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
- [x] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
- [x] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
- [x] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
- [x] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
- [x] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
- [x] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
- [x] ป้องกัน CSRF ด้วยการเทียบ `state`
### 4.2 Metadata (`metadataService.ts`)
- [ ] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
- [ ] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
- [ ] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
- [ ] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
- [ ] Constants: `ADDIN_SIGNATURE_GUID`
- [ ] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
- [x] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
- [x] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
- [x] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
- [x] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
- [x] Constants: `ADDIN_SIGNATURE_GUID`
- [x] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
### 4.3 File + API (`fileService.ts`, `apiClient.ts`, `versioningService.ts`)
- [ ] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
- [ ] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
- [ ] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
- [ ] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
- [x] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
- [x] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
- [x] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
- [x] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
### 4.4 Manifest
- [ ] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
- [x] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
หมายเหตุจากการทำจริง (2026-08-19):
- **`customXmlParts` อยู่บน `context.document` ไม่ใช่ `context`** (ตาม @types/office-js — WordApi 1.4) — ใช้ `context.document.customXmlParts.getByNamespace(...)` / `.add(...)`
- **Config inject ผ่าน webpack DefinePlugin** (`KEYCLOAK_AUTHORITY` / `KEYCLOAK_CLIENT_ID` / `API_BASE_URL` จาก env ตอน build, default dev = Keycloak 8080 / backend 5075) — อ่านที่ `src/config.ts`; `redirectUri` derive จาก `window.location.origin + "/auth-callback.html"` ให้ตรง realm-export เสมอ
- `auth-callback.html` ต้องอยู่ **root ของ dist** ตรง redirect URI — เพิ่ม entry `authCallback` + HtmlWebpackPlugin แยกใน webpack.config.js (template `src/taskpane/auth-callback.html` + logic ใน `auth-callback.ts`, ไม่ใช่ React)
- **Jest ใช้ babel-jest** (คอมไพล์ด้วย babel.config.json เดิม) ไม่ใช่ ts-jest — ts-jest@29 peerOptional ไม่รับ @babel/core 8 ของ scaffold; ผลข้างเคียง: test ไม่ type-check ตอนรัน (type ตรวจที่ `npm run build` ผ่าน ts-loader ซึ่งไล่ทั้ง program รวม *.test.ts แล้ว)
- jsdom env ไม่มี `Response`/`fetch`/`crypto.subtle` และ `crypto` เป็น getter-only (assign ตรงๆ เงียบ) → `src/test/setup.ts` ต้อง `Object.defineProperty` แล้วดึง webcrypto จาก node; mock HTTP จึงเป็น duck-typing object ไม่ใช่ `Response` จริง (ดู `src/test/httpMock.ts`)
- test ที่รอ dialog ต้อง **poll จน `displayDialogAsync` ถูกเรียก** แทน setTimeout(0) — บนเครื่องโหลด `crypto.subtle.digest` อาจยังไม่จบภายใน macrotask เดียว (มิฉะนั้น message หลุดก่อน handler ลงทะเบียน)
- **tsconfig เพิ่ม `types` จำกัด** (office-js, office-runtime, react, react-dom, jest, node) — ไม่อย่างนั้น `@types/babel__core` ที่ jest/babel-jest ดึงเข้ามาชนกับ @babel/types v8 จน tsc พัง
- **eslint: สร้าง `eslint.config.mjs` ของโปรเจกต์** (office-addin-lint จะใช้แทน config ของมันถ้ามีไฟล์นี้ใน root) เพราะ rule `no-navigational-load` ของ plugin 4.0.10 (a) crash กับ `context.load(obj)` แบบ 1 อาร์กิวเมนต์ (b) false positive กับ Word properties เช่น `tag` — ปิด rule นี้ + ปิด `no-undef`/`no-redeclare` สำหรับ .ts/.tsx (tsc ตรวจแทนแม่นกว่า)
- manifest: element `Requirements` วางระหว่าง `</Hosts>` กับ `<DefaultSettings>` — `npm run validate` ผ่าน และ platform analysis ยังครบ (Word desktop/web/iPad ทุกรุ่นรองรับ WordApi 1.5 + DialogAPI 1.1)
- `readTrackedProperties` ใช้ `getItemOrNullObject(...).load("value")` แล้วอ่าน `isNullObject` หลัง sync (null-object proxy ต้องถูก load อย่างน้อย 1 property ค่า isNullObject จึงแม่น)
- Services ยังไม่ถูก import เข้า taskpane entry (เป็นงาน Phase 5) — ครั้งแรกที่จะรันจริงใน Word คือตอน wire UI
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
> ✅ ตรวจผ่านจริง (2026-08-19): `npm run build` compiled successfully (0 errors), `npm test` 40/40 ผ่านทั้ง 4 ชุด (authService 14, metadataService 14, apiClient 8, fileService 4 รวมกรณี slice พังกลางทาง→ยัง closeAsync, closeAsync พัง→ยังได้ blob), `npm run lint` ผ่าน, `npm run validate` (manifest) ผ่าน
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**