implement phase 5 และ fix bugs

This commit is contained in:
Suphonchai Phoonsawat 2026-08-19 23:06:57 +07:00
parent f4b1c3e14b
commit 1bb850c6b8
40 changed files with 1507 additions and 429 deletions

View file

@ -15,7 +15,7 @@ MINIO_ROOT_USER=wordversioning-minio
MINIO_ROOT_PASSWORD=dev-local-pass MINIO_ROOT_PASSWORD=dev-local-pass
# --- Keycloak bootstrap admin (service: keycloak) --- # --- Keycloak bootstrap admin (service: keycloak) ---
# ใช้ล็อกอิน admin console ครั้งแรกที่ http://localhost:8080/admin/ # ใช้ล็อกอิน admin console ครั้งแรกที่ https://localhost:8443/admin/ (cert dev ต้อง install ก่อน — ดู README)
KC_BOOTSTRAP_ADMIN_USERNAME=admin KC_BOOTSTRAP_ADMIN_USERNAME=admin
KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass

View file

@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## โปรเจกต์นี้คืออะไร ## โปรเจกต์นี้คืออะไร
ระบบ **Word Versioning**: จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน: ระบบ **Word Versioning** (ชื่อที่แสดงใน UI: **e-Court Documents Versioning**): จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน:
| ส่วน | Stack | หน้าที่ | | ส่วน | Stack | หน้าที่ |
|---|---|---| |---|---|---|
@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
**สถานะปัจจุบัน: Phase 0–3 เสร็จ** (scaffold + infra + backend core FR-5–8 + backend template & generation FR-9–11) — งานต่อคือ implement phase 4–8 ในแผน (4–5 add-in กับ 6 web ทำขนานกันได้) **สถานะปัจจุบัน: Phase 0–5 เสร็จ** (backend ครบ + add-in ครบทั้ง services และ task pane UI — sideload ทดสอบจริงบน Word for Mac แล้ว แก้ปัญหาตกมุม 4 รอบ) — งานต่อคือ phase 6 (web portal), 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
@ -35,6 +35,9 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
8. **Ports:** add-in = `https://localhost:3000`, web = `http://localhost:5173` (ห้ามชนกัน), backend CORS ต้องอนุญาตทั้งคู่ 8. **Ports:** add-in = `https://localhost:3000`, web = `http://localhost:5173` (ห้ามชนกัน), backend CORS ต้องอนุญาตทั้งคู่
9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client 9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client
10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection 10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection
11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up`
12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง)
13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว
## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ ## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ
@ -44,16 +47,17 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore **Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend proxy stream เท่านั้น **MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง)
**Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่ **Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่
## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0) ## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0)
```bash ```bash
docker compose up -d # infra: PostgreSQL + MinIO + Keycloak docker compose up -d # infra: PostgreSQL + MinIO + Keycloak(https:8443)
python3 scripts/dev-token.py # token PKCE จริงสำหรับทดสอบ backend/Swagger (admin-cli ไม่มี aud ใช้ไม่ได้)
# backend # backend (http://localhost:5075 + https://localhost:7232 — dev เปิด https อัตโนมัติด้วย dev cert)
cd backend/WordVersioning.Api cd backend/WordVersioning.Api
dotnet run # API + swagger dotnet run # API + swagger
dotnet ef migrations add <Name> dotnet ef migrations add <Name>
@ -62,11 +66,11 @@ dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เ
# add-in (https://localhost:3000) # add-in (https://localhost:3000)
cd addin cd addin
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว — ทำก่อน docker compose up
npm run build # webpack build npm run build # webpack build
npm start # dev server — sideload ผ่าน manifest npm start # dev server — sideload ผ่าน manifest (symlink)
npm test npm test
npx jest src/services/metadataService.test.ts # รัน test เดี่ยว npx jest src/services/metadataService.test.ts # รัน test เดี่ยว
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว
# web portal (http://localhost:5173 — ห้ามใช้ 3000) # web portal (http://localhost:5173 — ห้ามใช้ 3000)
cd web cd web

View file

@ -1,4 +1,4 @@
# Word Versioning Add-in # e-Court Documents Versioning (Word Add-in)
ระบบจัดการ version history ของเอกสาร Word — สร้างเอกสารจาก template ผ่านเว็บ ติดตามทุกการแก้ไขผ่าน add-in ใน Word และ sync ข้อมูลสำคัญกลับฐานข้อมูลอัตโนมัติ ระบบจัดการ version history ของเอกสาร Word — สร้างเอกสารจาก template ผ่านเว็บ ติดตามทุกการแก้ไขผ่าน add-in ใน Word และ sync ข้อมูลสำคัญกลับฐานข้อมูลอัตโนมัติ
@ -35,11 +35,11 @@
| 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ | | 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ |
| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ | | 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ |
| 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ | | 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ |
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ⏳ รอ implement | | 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด |
| 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement | | 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement |
| 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement | | 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement |
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in มี services layer พร้อมใช้ครบพร้อม unit tests 40 ตัว (รอ Phase 5 เอาไปต่อ UI) **ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in ครบทั้ง services layer และ task pane UI พร้อม unit tests 45 ตัว
## เอกสาร ## เอกสาร
@ -58,6 +58,11 @@
dotnet tool install --global dotnet-ef --version 8.0.11 dotnet tool install --global dotnet-ef --version 8.0.11
``` ```
- Node.js LTS (add-in และ web) - Node.js LTS (add-in และ web)
- **Dev cert สำหรับ localhost** (ต้องทำ**ก่อน** `docker compose up` — Keycloak mount cert จากที่นี่):
```bash
cd addin && npx office-addin-dev-certs install
```
ทำครั้งเดียวต่อเครื่อง: สร้าง CA + cert ที่ trust ไว้ใน keychain ใช้ทั้ง add-in dev server (https://localhost:3000) และ Keycloak (https://localhost:8443)
### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak) ### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak)
@ -70,10 +75,12 @@ docker compose up -d
| บริการ | URL | เข้าด้วย | | บริการ | URL | เข้าด้วย |
|---|---|---| |---|---|---|
| Keycloak admin console | http://localhost:8080/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` | | Keycloak admin console | https://localhost:8443/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` |
| MinIO console | http://localhost:9001 | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` จาก `.env` | | MinIO console | http://localhost:9001 | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` จาก `.env` |
| PostgreSQL | `localhost:5432` | `POSTGRES_USER` / `POSTGRES_PASSWORD` จาก `.env`, ฐานข้อมูล `wordversioning` | | PostgreSQL | `localhost:5432` | `POSTGRES_USER` / `POSTGRES_PASSWORD` จาก `.env`, ฐานข้อมูล `wordversioning` |
> **ทำไม Keycloak ต้องเป็น HTTPS:** Office Dialog API ที่ add-in ใช้เปิดหน้า login **ปฏิเสธ http:// โดยสมบูรณ์** (error: "The HTTP protocol is not supported. Use HTTPS instead") — dev จึงให้ Keycloak เสิร์ฟ HTTPS ที่ port 8443 ด้วย cert ของ `office-addin-dev-certs` (CA ถูก trust ในเครื่องแล้ว Word/webview จึงเชื่อ) · ตัว issuer ใน token จะเป็น `https://localhost:8443/realms/word-versioning` ซึ่ง backend ตั้ง `Authentication:Authority` ตรงกัน
Keycloak จะ import realm `word-versioning` อัตโนมัติ (clients 3 ตัว + test user) จาก [keycloak/realm-export.json](keycloak/realm-export.json) — ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev: แก้ realm ให้แก้ที่ไฟล์นี้แล้วรัน `docker compose rm -sf keycloak && docker compose up -d` เพื่อ import ใหม่ (การแก้ผ่าน admin console จะหายเมื่อ recreate container) Keycloak จะ import realm `word-versioning` อัตโนมัติ (clients 3 ตัว + test user) จาก [keycloak/realm-export.json](keycloak/realm-export.json) — ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev: แก้ realm ให้แก้ที่ไฟล์นี้แล้วรัน `docker compose rm -sf keycloak && docker compose up -d` เพื่อ import ใหม่ (การแก้ผ่าน admin console จะหายเมื่อ recreate container)
**Test user สำหรับล็อกอิน:** `testuser` / `Test1234!` (เปลี่ยนได้ใน realm-export.json) **Test user สำหรับล็อกอิน:** `testuser` / `Test1234!` (เปลี่ยนได้ใน realm-export.json)
@ -85,9 +92,11 @@ MinIO จะมี bucket `docx-versions` และ `docx-templates` (private
```bash ```bash
cd backend/WordVersioning.Api cd backend/WordVersioning.Api
dotnet ef database update # ครั้งแรก / เมื่อมี migration ใหม่ dotnet ef database update # ครั้งแรก / เมื่อมี migration ใหม่
dotnet run # Swagger ที่ http://localhost:5075/swagger dotnet run # รันทั้ง http://localhost:5075 และ https://localhost:7232 (Swagger ใช้ https ก็ได้)
``` ```
> **ทำไม dev ต้องมี endpoint HTTPS ด้วย:** task pane ของ add-in เป็นหน้า `https://localhost:3000` และ **webview ของ Word (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ** — error ที่เห็นคือ "Load failed" — ส่วน CORS ก็ไม่ช่วยเพราะ request ไม่เคยถึง server · dev จึงเปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกับ Keycloak (`~/.office-addin-dev-certs`, ปิดได้ผ่าน `DevHttps:Enabled=false` ใน `appsettings.Development.json`) · add-in ชี้ API ที่ https ตัวนี้อัตโนมัติ
ค่าเชื่อมต่อ (DB/MinIO/Keycloak) อยู่ใน `appsettings.json` + `appsettings.Development.json` — override ผ่าน env ได้ทั้งหมด เช่น `ConnectionStrings__Default`, `MinIO__Endpoint`, `Authentication__Authority` ค่าเชื่อมต่อ (DB/MinIO/Keycloak) อยู่ใน `appsettings.json` + `appsettings.Development.json` — override ผ่าน env ได้ทั้งหมด เช่น `ConnectionStrings__Default`, `MinIO__Endpoint`, `Authentication__Authority`
**API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`): **API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`):
@ -96,7 +105,8 @@ dotnet run # Swagger ที่ http://localhost:5075/swagger
# Documents / versions (FR-5–7, 11) # Documents / versions (FR-5–7, 11)
POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync
GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields) GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields)
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO) GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO)
# Generate จาก template (FR-10) # Generate จาก template (FR-10)
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata
@ -117,12 +127,12 @@ DELETE /api/templates/{id} # deactivate (s
### ขอ token ทดสอบ (dev) ### ขอ token ทดสอบ (dev)
ทุก client บังคับ PKCE (ปิด direct grants ตาม spec) — วิธีเร็วสุดตอนทดสอบคือขอผ่าน client `admin-cli` ของ realm: ทุก client ใน realm บังคับ PKCE (ปิด direct grants ตาม spec) และ token ของ `admin-cli` **ไม่มี claim `aud`** จึงเรียก backend ไม่ผ่าน (401) — ใช้สคริปต์นี้เดิน PKCE flow จริงแทน (login ด้วย test user ให้อัตโนมัติ):
```bash ```bash
curl -s -X POST http://localhost:8080/realms/word-versioning/protocol/openid-connect/token \ python3 scripts/dev-token.py # ปริ้น access token
-d "client_id=admin-cli&grant_type=password&username=testuser&password=Test1234!" \ python3 scripts/dev-token.py --clip # หรือคัดลอกเข้า clipboard
| jq -r .access_token TOKEN=$(python3 scripts/dev-token.py) # เก็บใส่ตัวแปร
``` ```
แล้วลอง upload: แล้วลอง upload:
@ -186,13 +196,13 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error) แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error)
### 3) Add-in (services layer พร้อม — UI ตามมาใน Phase 5) ### 3) Add-in (ครบทั้ง services layer และ task pane UI)
```bash ```bash
cd addin cd addin
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost) npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
npm run build # webpack build + type-check ทั้ง program npm run build # webpack build + type-check ทั้ง program
npm test # Jest — unit tests ของ services layer (40 ตัว) npm test # Jest — unit tests (45 ตัว)
npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์) npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
npm run validate # ตรวจ manifest.xml npm run validate # ตรวจ manifest.xml
npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml
@ -206,21 +216,35 @@ npm start # dev server https://localhost:3000 — sid
| `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) | | `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) |
| `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ | | `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ |
| `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in | | `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in |
| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion | | `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion + getDownloadLink (presigned) |
| `versionFlow.ts` | orchestration ของ Start Versioning / Save & Version ตามลำดับ 7 ขั้นตอนของ spec |
หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json) หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json)
**Config ฝั่ง add-in** ฉีดตอน build ผ่าน webpack DefinePlugin (อ่านจาก env — default ชี้ dev ในพื้นที่): **Config ฝั่ง add-in** ฉีดตอน build ผ่าน webpack DefinePlugin (อ่านจาก env — default ชี้ dev ในพื้นที่):
```bash ```bash
KEYCLOAK_AUTHORITY=http://localhost:8080/realms/word-versioning \ KEYCLOAK_AUTHORITY=https://localhost:8443/realms/word-versioning \
API_BASE_URL=http://localhost:5075 \ API_BASE_URL=https://localhost:7232 \
npm run build npm run build
``` ```
(ตัวแปรที่เหลือ: `KEYCLOAK_CLIENT_ID` — default `word-addin-officejs`) (ตัวแปรที่เหลือ: `KEYCLOAK_CLIENT_ID` — default `word-addin-officejs`)
**ข้อจำกัดชั่วคราว:** task pane ยังเป็นหน้า demo ของ scaffold — Phase 5 จะเอา services ชุดนี้ไป wire เป็น UI จริง (sign-in → detect → Save & Version → history) **Task pane UI (Phase 5)** — state machine `checking → signed-out → untracked → tracking`:
- **Sign-in gate (FR-1)** — ยังไม่ login → ปุ่ม Sign in (เปิด Keycloak ผ่าน Office Dialog API) · login แล้วตรวจ property `SavedByAddIn` ในไฟล์: ตรง → โหลด version history ทันที (ไฟล์ที่สร้างจากเว็บเข้า state นี้เลย เห็น v1 โดยไม่ต้องกดอะไร) / ไม่ตรง → ปุ่ม **Start Versioning This Document**
- **Save & Version (FR-2)** — confirm dialog → stamp properties → อัปเดต Custom XML Part → native save → ดึงไฟล์ + extract fields (FR-11) → upload → refresh history · ทุกขั้นมี loading + error ไม่มี unhandled rejection
- **Soft reminder (FR-3)** — ระหว่าง tracking ถามทุก 30 วิ ว่ามีแก้ไขที่ยังไม่ได้ version → banner เตือน (ไม่ block)
- **Version history (FR-4)** — รายการ v เวลา/ผู้สร้าง + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์)
- **Session หมดอายุ (AC-9)** — 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in พร้อมข้อความ
**Sideload เพื่อทดสอบใน Word จริง** (ต้องรัน infra + backend + `npm start` ก่อน):
- **Word Desktop (Mac/Windows)**: `cd addin && npm start` (ใช้ office-addin-debugging sideload ให้อัตโนมัติ) หรือ insert manual: Word → Insert → My Add-ins → Upload My Add-in → เลือก `addin/manifest.xml`
- **Word Online**: ไฟล์ใดก็ได้ใน OneDrive/SharePoint → Insert → Office Add-ins → Upload My Add-in → เลือก `addin/manifest.xml`
- ไล่ทดสอบตาม acceptance criteria: login ผ่าน dialog → เปิดไฟล์ใหม่เห็นปุ่ม Start Versioning → กดแล้วได้ v1 → แก้ไขแล้ว Save & Version → history เพิ่ม + ดาวน์โหลด version เก่าเปิดได้
- ⚠️ **ห้าม `cp` ทับ manifest ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/`** — `npm start` สร้าง symlink ไว้ การทับทำให้ครั้งถัดไปขึ้น `EEXIST` — ถาวรแก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word (ถ้าเจอ EEXIST แล้ว: ลบไฟล์ใน wef ทิ้งแล้ว `npm start` ใหม่)
### 4) Web Portal (ยังเป็น scaffold) ### 4) Web Portal (ยังเป็น scaffold)
@ -232,6 +256,22 @@ logic จริง (login, template UI, dynamic form) มาตาม Phase 6
> คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน > คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน
## Troubleshooting (จากการทดสอบจริงบน Word for Mac)
ปัญหาที่เจอตอน sideload test รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน:
| อาการ | สาเหตุ | วิธีแก้ |
|---|---|---|
| กด Sign in แล้ว "The HTTP protocol is not supported. Use HTTPS instead" | Office Dialog API **ปฏิเสธ http:// โดยสมบูรณ์** แต่ Keycloak dev เดิมรัน http://8080 | Keycloak เสิร์ฟ HTTPS ที่ `https://localhost:8443` ด้วย cert ของ office-addin-dev-certs (docker-compose mount `~/.office-addin-dev-certs` เข้า container) |
| Login ผ่านแต่ทุก API call ขึ้น "Load failed" | **WKWebView บล็อก fetch จากหน้า https ไป http (mixed content)** — task pane เป็น `https://localhost:3000` แต่ API เป็น `http://localhost:5075` (CORS ไม่เกี่ยว — ตรวจแล้วเรียบร้อย) | backend เปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกัน (`DevHttps:*` ใน `appsettings.Development.json`) — add-in ชี้อันนี้อัตโนมัติ |
| กด Start Versioning แล้ว "รูปแบบข้อมูลของ slice ไม่รองรับ" | host แต่ละตัวส่ง `slice.data` คนละรูป (Win = ArrayBuffer/view, **Mac = base64 string หรือ number[]**) | `fileService.sliceDataToBytes()` normalize ครบทั้ง 4 รูปแบบ |
| กดดาวน์โหลด version แล้ว**ไม่มีไฟล์โผล่ที่ไหนเลย** | **webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ** | ขอ presigned URL อายุ 2 นาทีจาก `download-link` แล้วเปิดใน**เบราว์เซอร์ของเครื่อง** (`openBrowserWindow`) — ไฟล์ลงโฟลเดอร์ Downloads ของเบราว์เซอร์ |
| แก้ webpack.config.js แล้วค่าเก่ายังติดอยู่ | webpack **ไม่ restart เองเมื่อ config เปลี่ยน** + dev-server ตัวเก่าถือ port 3000 อยู่ (ตัวใหม่ขึ้นไม่ได้) | kill dev-server เดิม (`lsof -ti :3000 | xargs kill`) แล้ว `npm start` ใหม่ทุกครั้งที่แก้ config |
| `npm start` ขึ้น `EEXIST ... wef/...manifest.xml` | มีการ `cp` ทับ symlink ของ sideload จนกลายเป็นไฟล์ธรรมดา | ลบไฟล์นั้นใน wef folder ทิ้งแล้ว `npm start` ใหม่ (ดูคำเตือนในหัวข้อ Sideload) |
| curl token จาก `admin-cli` แล้วเรียก backend ได้ 401 | token ของ admin-cli **ไม่มี claim `aud`** (ไม่ได้อยู่ใน realm-export จึงไม่มี audience mapper) | ใช้ `python3 scripts/dev-token.py` (เดิน PKCE จริงด้วย client `word-versioning-web`) |
**กติกาที่สรุปได้:** ใน dev ทุก service ที่ task pane (หน้า https) จะเรียก **ต้องเป็น HTTPS ด้วย cert จาก office-addin-dev-certs ทั้งหมด** — Keycloak (8443) และ backend (7232)
## เทคโนโลยี ## เทคโนโลยี
- **Add-in**: Office.js (WordApi 1.5+), TypeScript, React, Fluent UI, webpack — scaffold ด้วย `yo office` - **Add-in**: Office.js (WordApi 1.5+), TypeScript, React, Fluent UI, webpack — scaffold ด้วย `yo office`

View file

@ -1,143 +0,0 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/teams/v1.17/MicrosoftTeams.schema.json",
"id": "f2b103f1-1ab1-4e1b-8f0b-072aa3d4e19d",
"manifestVersion": "1.17",
"version": "1.0.0",
"name": {
"short": "Contoso Task Pane Add-in",
"full": "Contoso Task Pane Add-in"
},
"description": {
"short": "A template to get started.",
"full": "This is the template to get started."
},
"developer": {
"name": "Contoso",
"websiteUrl": "https://www.contoso.com",
"privacyUrl": "https://www.contoso.com/privacy",
"termsOfUseUrl": "https://www.contoso.com/servicesagreement"
},
"icons": {
"outline": "assets/outline.png",
"color": "assets/color.png"
},
"accentColor": "#230201",
"localizationInfo": {
"defaultLanguageTag": "en-us",
"additionalLanguages": []
},
"authorization": {
"permissions": {
"resourceSpecific": [
{
"name": "Mailbox.ReadWrite.User",
"type": "Delegated"
}
]
}
},
"validDomains": ["contoso.com"],
"extensions": [
{
"requirements": {
"scopes": ["mail"],
"capabilities": [
{ "name": "Mailbox", "minVersion": "1.3" }
]
},
"runtimes": [
{
"requirements": {
"capabilities": [
{ "name": "Mailbox", "minVersion": "1.3" }
]
},
"id": "TaskPaneRuntime",
"type": "general",
"code": {
"page": "https://localhost:3000/taskpane.html"
},
"lifetime": "short",
"actions": [
{
"id": "TaskPaneRuntimeShow",
"type":"openPage",
"pinnable": false,
"view": "dashboard"
}
]
},
{
"id": "CommandsRuntime",
"type": "general",
"code": {
"page": "https://localhost:3000/commands.html",
"script": "https://localhost:3000/commands.js"
},
"lifetime": "short",
"actions": [
{
"id": "action",
"type": "executeFunction"
}
]
}
],
"ribbons": [
{
"contexts": [
"mailCompose"
],
"tabs": [
{
"builtInTabId": "TabDefault",
"groups": [
{
"id": "msgComposeGroup",
"label": "Contoso Add-in",
"icons": [
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
],
"controls": [
{
"id": "msgComposeOpenPaneButton",
"type": "button",
"label": "Show Task Pane",
"icons": [
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
],
"supertip": {
"title": "Show Task Pane",
"description": "Opens a task pane."
},
"actionId": "TaskPaneRuntimeShow"
},
{
"id": "ActionButton",
"type": "button",
"label": "Perform an action",
"icons": [
{ "size": 16, "url": "https://localhost:3000/assets/icon-16.png" },
{ "size": 32, "url": "https://localhost:3000/assets/icon-32.png" },
{ "size": 80, "url": "https://localhost:3000/assets/icon-80.png" }
],
"supertip": {
"title": "Perform an action",
"description": "Perform an action when clicked."
},
"actionId": "action"
}
]
}
]
}
]
}
]
}
]
}

View file

@ -4,8 +4,8 @@
<Version>1.0.0.0</Version> <Version>1.0.0.0</Version>
<ProviderName>Contoso</ProviderName> <ProviderName>Contoso</ProviderName>
<DefaultLocale>en-US</DefaultLocale> <DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="addin"/> <DisplayName DefaultValue="e-Court Documents Versioning"/>
<Description DefaultValue="A template to get started."/> <Description DefaultValue="Opens the e-Court Documents Versioning task pane."/>
<IconUrl DefaultValue="https://localhost:3000/assets/icon-32.png"/> <IconUrl DefaultValue="https://localhost:3000/assets/icon-32.png"/>
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/> <HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
<SupportUrl DefaultValue="https://www.contoso.com/help"/> <SupportUrl DefaultValue="https://www.contoso.com/help"/>
@ -22,6 +22,8 @@
<Sets DefaultMinVersion="1.1"> <Sets DefaultMinVersion="1.1">
<Set Name="WordApi" MinVersion="1.5"/> <Set Name="WordApi" MinVersion="1.5"/>
<Set Name="DialogAPI" MinVersion="1.1"/> <Set Name="DialogAPI" MinVersion="1.1"/>
<!-- เปิด presigned download URL ในเบราว์เซอร์ของเครื่อง (webview ของ Word ทิ้ง blob download) -->
<Set Name="OpenBrowserWindowApi" MinVersion="1.1"/>
</Sets> </Sets>
</Requirements> </Requirements>
<DefaultSettings> <DefaultSettings>
@ -82,12 +84,12 @@
</bt:Urls> </bt:Urls>
<bt:ShortStrings> <bt:ShortStrings>
<bt:String id="GetStarted.Title" DefaultValue="Get started with your sample add-in!"/> <bt:String id="GetStarted.Title" DefaultValue="Get started with your sample add-in!"/>
<bt:String id="CommandsGroup.Label" DefaultValue="Commands Group"/> <bt:String id="CommandsGroup.Label" DefaultValue="e-Court Add-in"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Show Task Pane"/> <bt:String id="TaskpaneButton.Label" DefaultValue="e-Court Documents Versioning"/>
</bt:ShortStrings> </bt:ShortStrings>
<bt:LongStrings> <bt:LongStrings>
<bt:String id="GetStarted.Description" DefaultValue="Your sample add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/> <bt:String id="GetStarted.Description" DefaultValue="Your sample add-in loaded successfully. Go to the HOME tab and click the 'Show Task Pane' button to get started."/>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Click to Show a Task Pane"/> <bt:String id="TaskpaneButton.Tooltip" DefaultValue="Opens the e-Court Documents Versioning task pane."/>
</bt:LongStrings> </bt:LongStrings>
</Resources> </Resources>
</VersionOverrides> </VersionOverrides>

View file

@ -5,12 +5,12 @@
*/ */
export const config = { export const config = {
keycloak: { keycloak: {
/** เช่น http://localhost:8080/realms/word-versioning */ /** เช่น https://localhost:8443/realms/word-versioning (ต้องเป็น HTTPS เสมอ — Office Dialog API ปฏิเสธ http) */
authority: __KEYCLOAK_AUTHORITY__, authority: __KEYCLOAK_AUTHORITY__,
/** public client ของ add-in (PKCE S256 บังคับ) */ /** public client ของ add-in (PKCE S256 บังคับ) */
clientId: __KEYCLOAK_CLIENT_ID__, clientId: __KEYCLOAK_CLIENT_ID__,
}, },
/** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น http://localhost:5075 */ /** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น https://localhost:7232 (ต้องเป็น HTTPS — webview บล็อก http) */
apiBaseUrl: __API_BASE_URL__, apiBaseUrl: __API_BASE_URL__,
/** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */ /** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */
get redirectUri(): string { get redirectUri(): string {

View file

@ -2,7 +2,7 @@ import * as authService from "./authService";
import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient"; import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
const API_URL = "http://localhost:5075"; const API_URL = "https://localhost:7232";
beforeEach(() => { beforeEach(() => {
authService.signOut(); authService.signOut();

View file

@ -2,7 +2,8 @@ import { createHash } from "crypto";
import * as authService from "./authService"; import * as authService from "./authService";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token"; const TOKEN_ENDPOINT =
"https://localhost:8443/realms/word-versioning/protocol/openid-connect/token";
function base64Url(buffer: Buffer): string { function base64Url(buffer: Buffer): string {
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
@ -110,7 +111,7 @@ describe("buildAuthorizationUrl", () => {
authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" }) authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" })
); );
expect(url.origin).toBe("http://localhost:8080"); expect(url.origin).toBe("https://localhost:8443");
expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth"); expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth");
expect(url.searchParams.get("client_id")).toBe("word-addin-officejs"); expect(url.searchParams.get("client_id")).toBe("word-addin-officejs");
expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html"); expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html");
@ -138,11 +139,11 @@ describe("decodeJwtExpiry", () => {
describe("login (Office Dialog + token exchange)", () => { describe("login (Office Dialog + token exchange)", () => {
it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => { it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => {
const office = installOfficeDialogMock(); const office = installOfficeDialogMock();
let exchangedBody: URLSearchParams | null = null; const exchanged: { body?: URLSearchParams } = {};
const fetchMock = installFetchMock(); const fetchMock = installFetchMock();
fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => { fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => {
expect(String(input)).toBe(TOKEN_ENDPOINT); expect(String(input)).toBe(TOKEN_ENDPOINT);
exchangedBody = new URLSearchParams(String(init?.body)); exchanged.body = new URLSearchParams(String(init?.body));
return mockResponse({ return mockResponse({
status: 200, status: 200,
body: { body: {
@ -169,10 +170,10 @@ describe("login (Office Dialog + token exchange)", () => {
expect(office.dialog.close).toHaveBeenCalled(); expect(office.dialog.close).toHaveBeenCalled();
// verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง) // verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง)
const verifier = exchangedBody?.get("code_verifier") ?? ""; const verifier = exchanged.body?.get("code_verifier") ?? "";
expect(exchangedBody?.get("grant_type")).toBe("authorization_code"); expect(exchanged.body?.get("grant_type")).toBe("authorization_code");
expect(exchangedBody?.get("code")).toBe("the-auth-code"); expect(exchanged.body?.get("code")).toBe("the-auth-code");
expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs"); expect(exchanged.body?.get("client_id")).toBe("word-addin-officejs");
expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge); expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge);
}); });

View file

@ -216,6 +216,32 @@ export function decodeJwtExpiry(token: string): number | null {
} }
} }
/** อ่านชื่อผู้ใช้จาก access token ปัจจุบัน (`preferred_username` แล้ว fallback `sub`) — ใช้ใส่ใน XML history */
export function getUsername(): string | undefined {
if (!tokenState.accessToken) {
return undefined;
}
const parts = tokenState.accessToken.split(".");
if (parts.length !== 3) {
return undefined;
}
try {
const payload = JSON.parse(base64UrlDecode(parts[1])) as {
preferred_username?: unknown;
sub?: unknown;
};
if (typeof payload.preferred_username === "string" && payload.preferred_username) {
return payload.preferred_username;
}
if (typeof payload.sub === "string" && payload.sub) {
return payload.sub;
}
return undefined;
} catch {
return undefined;
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// State transitions // State transitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -0,0 +1,10 @@
/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */
export function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === "string" && error) {
return error;
}
return fallback;
}

View file

@ -9,10 +9,28 @@ interface FileMockState {
/** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */ /** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */
function installOfficeFileMock( function installOfficeFileMock(
slices: Uint8Array[], slices: Uint8Array[],
options: { failSliceIndex?: number; failClose?: boolean; sliceDataForm?: "buffer" | "view" } = {} options: {
failSliceIndex?: number;
failClose?: boolean;
/** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */
sliceDataForm?: "buffer" | "view" | "array" | "base64";
} = {}
): FileMockState { ): FileMockState {
const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 }; const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 };
const toSliceData = (bytes: Uint8Array): unknown => {
switch (options.sliceDataForm) {
case "view":
return bytes;
case "array":
return Array.from(bytes);
case "base64":
return Buffer.from(bytes).toString("base64");
default:
return bytes.buffer;
}
};
const file = { const file = {
sliceCount: slices.length, sliceCount: slices.length,
getSliceAsync: (index: number, callback: (result: unknown) => void) => { getSliceAsync: (index: number, callback: (result: unknown) => void) => {
@ -20,9 +38,10 @@ function installOfficeFileMock(
if (options.failSliceIndex === index) { if (options.failSliceIndex === index) {
callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } }); callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } });
} else { } else {
// host จริงอาจส่งมาเป็น ArrayBuffer หรือ view — ทดสอบทั้งคู่ callback({
const data = options.sliceDataForm === "view" ? slices[index] : slices[index].buffer; status: "succeeded",
callback({ status: "succeeded", value: { index, data, size: slices[index].byteLength } }); value: { index, data: toSliceData(slices[index]), size: slices[index].byteLength },
});
} }
}, },
closeAsync: (callback?: (result: unknown) => void) => { closeAsync: (callback?: (result: unknown) => void) => {
@ -81,6 +100,26 @@ describe("getCurrentDocumentFile", () => {
expect(state.closeCalls).toBe(1); expect(state.closeCalls).toBe(1);
}); });
it("slice มาเป็น byte array ธรรมดา number[] (Word บน Mac) ก็ประกอบได้", async () => {
const state = installOfficeFileMock([bytes(10, 20), bytes(30, 40)], { sliceDataForm: "array" });
const blob = await getCurrentDocumentFile();
expect(blob.size).toBe(4);
expect(state.closeCalls).toBe(1);
});
it("slice มาเป็น base64 string ก็ decode แล้วประกอบได้", async () => {
const state = installOfficeFileMock([bytes(200, 100, 50), bytes(255)], {
sliceDataForm: "base64",
});
const blob = await getCurrentDocumentFile();
expect(blob.size).toBe(4);
expect(state.closeCalls).toBe(1);
});
it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => { it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => {
const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 }); const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 });

View file

@ -35,20 +35,48 @@ function getFileAsync(): Promise<Office.File> {
}); });
} }
/** host ส่ง slice มาเป็น ArrayBuffer หรือ view — รับทั้งคู่แล้วคืนเป็น BlobPart ตรงๆ */ /**
* host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบบแค่ "byte array"):
* - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view
* - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[])
* ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob
*/
export function sliceDataToBytes(data: unknown): Uint8Array<ArrayBuffer> {
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
const view = data as ArrayBufferView;
return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength);
}
if (typeof data === "string") {
// base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode)
const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, "");
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) {
return Uint8Array.from(data);
}
throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`);
}
/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */
function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> { function getSliceAsync(file: Office.File, sliceIndex: number): Promise<BlobPart> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
file.getSliceAsync(sliceIndex, (result) => { file.getSliceAsync(sliceIndex, (result) => {
if (result.status === Office.AsyncResultStatus.Succeeded) { if (result.status === Office.AsyncResultStatus.Succeeded) {
const data: unknown = result.value.data; try {
if (data instanceof ArrayBuffer) { resolve(sliceDataToBytes(result.value.data));
resolve(data); } catch (error) {
} else if (ArrayBuffer.isView(data)) { reject(
const view = data as ArrayBufferView; error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)
resolve(new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength)); );
} else {
reject(new Error("รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)"));
} }
} else { } else {
reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)); reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`));

View file

@ -0,0 +1,216 @@
import * as authService from "./authService";
import { ADDIN_SIGNATURE_GUID, buildVersionHistoryXml } from "./metadataService";
import { saveAndVersion, startVersioning } from "./versionFlow";
import { installWordMock } from "../test/wordMock";
import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock";
/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */
function installOfficeDocumentMock(url: string | undefined): void {
const slice = new Uint8Array([1, 2, 3, 4]);
(globalThis as unknown as Record<string, unknown>).Office = {
FileType: { Compressed: "compressed", Text: "text" },
AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" },
context: {
document: {
url,
getFileAsync: (
_fileType: unknown,
_options: unknown,
callback: (result: unknown) => void
) => {
callback({
status: "succeeded",
value: {
sliceCount: 1,
getSliceAsync: (index: number, cb: (result: unknown) => void) =>
cb({ status: "succeeded", value: { index, data: slice.buffer } }),
closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }),
},
});
},
},
},
};
}
function seedSession(): void {
authService.setSessionForTest(
makeFakeJwt(Date.now() + 600_000, "user-sub", { preferred_username: "tester" }),
"refresh-1",
Date.now() + 600_000
);
}
function parseUploadCall(call: [string, { body?: FormData }]): { url: URL; form: FormData } {
return { url: new URL(call[0]), form: call[1].body as FormData };
}
beforeEach(() => {
seedSession();
});
afterEach(() => {
authService.signOut();
});
describe("startVersioning (FR-1 / AC-5)", () => {
it("stamp รอบแรกครบ → save → upload v1 พร้อม fields + ชื่อไฟล์จาก URL → เขียน XML part v1", async () => {
const state = installWordMock({
contentControls: [
{ tag: "employee_name", text: "สมชาย ใจดี" },
{ tag: "salary", text: "50000" },
],
});
installOfficeDocumentMock("C:\\Users\\tester\\Documents\\สัญญาจ้าง.docx");
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "backend-version-1", versionNumber: 1 } })
);
const result = await startVersioning();
// ผลลัพธ์ผูกกันทั้งชุด
expect(result).toEqual({
documentId: result.documentId,
versionId: "backend-version-1",
versionNumber: 1,
});
expect(result.documentId).toMatch(/^[0-9a-f-]{36}$/i);
// ครบทั้ง 4 properties + SavedByAddIn = GUID ของ add-in
expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID);
expect(state.properties.DocumentTrackingId).toBe(result.documentId);
expect(state.properties.LastVersionId).toMatch(/^[0-9a-f-]{36}$/i);
expect(state.properties.LastVersionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
// เรียก native save หนึ่งครั้ง (FR-2 ขั้นตอนที่ 4)
expect(state.saveCalls).toBe(1);
// upload ไปที่ documentId ที่ generate + แนบ fields + clientVersionId + ชื่อไฟล์จาก URL
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(`${url.origin}${url.pathname}`).toBe(
`https://localhost:7232/api/documents/${result.documentId}/versions`
);
expect(String(form.get("clientVersionId"))).toBe(state.properties.LastVersionId);
expect(JSON.parse(String(form.get("fields")))).toEqual({
employee_name: "สมชาย ใจดี",
salary: "50000",
});
expect(String((form.get("file") as File).name)).toBe("สัญญาจ้าง.docx");
// XML part มี entry v1 ผูกกับ clientVersionId เดียวกับ LastVersionId
expect(state.xmlParts).toHaveLength(1);
expect(state.xmlParts[0].xml).toContain('id="');
expect(state.xmlParts[0].xml).toContain('number="1"');
expect(state.xmlParts[0].xml).toContain('user="tester"');
});
it("URL ไม่มี (Word Online) → ชื่อไฟล์ fallback", async () => {
installWordMock({});
installOfficeDocumentMock(undefined);
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } })
);
await startVersioning();
const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(String((form.get("file") as File).name)).toMatch(/\.docx$/);
});
});
describe("saveAndVersion (FR-2 / AC-7, 13)", () => {
it("stamp → append XML entry (เดาเลขถัดไป) → save → upload พร้อม fields ล่าสุด", async () => {
const state = installWordMock({
properties: {
SavedByAddIn: ADDIN_SIGNATURE_GUID,
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
LastVersionId: "old-client-version-id",
},
xmlParts: [
buildVersionHistoryXml([
{
id: "old-client-version-id",
number: 1,
timestamp: "2026-08-19T09:00:00Z",
user: "tester",
},
]),
],
contentControls: [{ tag: "salary", text: "60000" }],
});
installOfficeDocumentMock(undefined);
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
);
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
expect(result).toEqual({ versionId: "backend-version-2", versionNumber: 2 });
// properties: tracking id คงเดิม, LastVersionId เปลี่ยนเป็น clientVersionId ใหม่
expect(state.properties.DocumentTrackingId).toBe("11111111-1111-1111-1111-111111111111");
const newLastVersionId = state.properties.LastVersionId;
expect(newLastVersionId).not.toBe("old-client-version-id");
expect(state.saveCalls).toBe(1);
// XML part: 2 entries เรียง v2 ก่อน v1
expect(state.xmlParts).toHaveLength(1);
const xml = state.xmlParts[0].xml;
expect(xml).toContain('number="2"');
expect(xml).toContain('number="1"');
expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"'));
// upload ไปยัง document เดิม + fields ล่าสุด
const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]);
expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions");
expect(String(form.get("clientVersionId"))).toBe(newLastVersionId);
expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" });
});
it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => {
const state = installWordMock({
properties: {
SavedByAddIn: ADDIN_SIGNATURE_GUID,
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
LastVersionId: "old",
},
xmlParts: [buildVersionHistoryXml([{ id: "old", number: 1, timestamp: "t", user: "u" }])],
});
installOfficeDocumentMock(undefined);
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "backend-version-5", versionNumber: 5 } })
);
const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1);
expect(result.versionNumber).toBe(5);
// entry ใหม่ถูกแก้จาก 2 (ที่เดา) เป็น 5 (ของจริง)
const xml = state.xmlParts[0].xml;
expect(xml).toContain('number="5"');
expect(xml).not.toContain('number="2"');
expect(xml).toContain('number="1"');
});
it("XML part เขียนไม่ได้ (known bug บน Mac) → flow ยังสำเร็จต่อ (AC-10)", async () => {
installWordMock({
properties: {
SavedByAddIn: ADDIN_SIGNATURE_GUID,
DocumentTrackingId: "11111111-1111-1111-1111-111111111111",
},
failXmlAdd: true,
});
installOfficeDocumentMock(undefined);
const fetchMock = installFetchMock();
fetchMock.mockImplementationOnce(async () =>
mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } })
);
await expect(saveAndVersion("11111111-1111-1111-1111-111111111111", 1)).resolves.toEqual({
versionId: "backend-version-2",
versionNumber: 2,
});
});
});

View file

@ -0,0 +1,159 @@
/* global Word, Office */
import * as authService from "./authService";
import { getCurrentDocumentFile } from "./fileService";
import {
generateGuid,
readContentControlFields,
readVersionHistoryFromXml,
stampTrackedProperties,
writeVersionHistoryToXml,
XmlVersionEntry,
} from "./metadataService";
import { uploadVersion } from "./versioningService";
/**
* Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้):
*
* - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track
* - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด:
* stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save()
* → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history
*
* หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
* versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว
* (backend คือ source of truth ของ id จริงอยู่ดี)
*/
export interface StartVersioningResult {
documentId: string;
versionId: string;
versionNumber: number;
}
export interface SaveAndVersionResult {
versionId: string;
versionNumber: number;
}
/** เลข version ถัดไปตามที่ client เดา (history ล่าสุด + 1) — ใช้เขียน XML part ก่อนรู้ค่าจริงจาก backend */
function predictNextVersionNumber(latestVersionNumber: number): number {
return latestVersionNumber + 1;
}
/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */
function guessDocumentFileName(): string | undefined {
try {
const url = Office.context.document.url;
if (!url) {
return undefined;
}
const name = url.split(/[\\/]/).pop();
if (!name) {
return undefined;
}
return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`;
} catch {
return undefined;
}
}
/** `context.document.save(Word.SaveBehavior.save)` — trigger native save ตาม FR-2 ขั้นตอนที่ 4 */
async function saveDocument(): Promise<void> {
await Word.run(async (context) => {
context.document.save(Word.SaveBehavior.save);
await context.sync();
});
}
async function uploadCurrentFile(
documentId: string,
clientVersionId: string,
fields: Record<string, string>
): Promise<{ versionId: string; versionNumber: number }> {
const file = await getCurrentDocumentFile();
return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName());
}
/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 */
export async function startVersioning(
onProgress?: (message: string) => void
): Promise<StartVersioningResult> {
const documentId = generateGuid();
const clientVersionId = generateGuid();
const timestamp = new Date().toISOString();
// 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่
onProgress?.("กำลังบันทึก metadata ของเอกสาร…");
await stampTrackedProperties({
documentTrackingId: documentId,
lastVersionId: clientVersionId,
lastVersionTimestamp: timestamp,
});
// 2) XML part (secondary — fail ได้เงียบๆ)
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
await writeVersionHistoryToXml([
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" },
]);
// 3) native save แล้ว capture ไฟล์ + fields
onProgress?.("กำลังบันทึกไฟล์…");
await saveDocument();
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields();
// 4) upload version แรก
onProgress?.("กำลังอัปโหลด version 1…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
return { documentId, ...result };
}
/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว) */
export async function saveAndVersion(
documentId: string,
latestVersionNumber: number,
onProgress?: (message: string) => void
): Promise<SaveAndVersionResult> {
const clientVersionId = generateGuid();
const timestamp = new Date().toISOString();
const predictedNumber = predictNextVersionNumber(latestVersionNumber);
const user = authService.getUsername() ?? "unknown";
// 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย)
onProgress?.("กำลังบันทึก metadata ของ version ใหม่…");
await stampTrackedProperties({
lastVersionId: clientVersionId,
lastVersionTimestamp: timestamp,
});
// 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ
onProgress?.("กำลังอัปเดต version history ในไฟล์…");
const entries = await readVersionHistoryFromXml();
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
await writeVersionHistoryToXml(entries);
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11)
onProgress?.("กำลังบันทึกไฟล์…");
await saveDocument();
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields();
// 4) upload
onProgress?.("กำลังอัปโหลด version ใหม่…");
const result = await uploadCurrentFile(documentId, clientVersionId, fields);
// 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
if (result.versionNumber !== predictedNumber) {
const corrected = await readVersionHistoryFromXml();
const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId);
if (entry) {
entry.number = result.versionNumber;
corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข
await writeVersionHistoryToXml(corrected);
}
}
return result;
}

View file

@ -42,6 +42,26 @@ export function buildDownloadUrl(documentId: string, versionId: string): string
return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`; return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`;
} }
export interface VersionDownloadLink {
url: string;
/** ISO-8601 เวลาที่ URL หมดอายุ */
expiresAt: string;
}
/**
* FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้
* (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน)
*/
export async function getDownloadLink(
documentId: string,
versionId: string
): Promise<VersionDownloadLink> {
return apiGetJson<VersionDownloadLink>(
`/api/documents/${documentId}/versions/${versionId}/download-link`,
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ"
);
}
/** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */ /** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */
export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> { export async function downloadVersion(documentId: string, versionId: string): Promise<Blob> {
const response = await apiFetch(buildDownloadUrl(documentId, versionId)); const response = await apiFetch(buildDownloadUrl(documentId, versionId));

View file

@ -1,45 +1,430 @@
import * as React from "react"; /* global Word, Office */
import Header from "./Header";
import HeroList, { HeroListItem } from "./HeroList";
import TextInsertion from "./TextInsertion";
import { makeStyles } from "@fluentui/react-components";
import { Ribbon24Regular, LockOpen24Regular, DesignIdeas24Regular } from "@fluentui/react-icons";
import { insertText } from "../taskpane";
interface AppProps { import * as React from "react";
title: string; import {
} Button,
MessageBar,
MessageBarBody,
MessageBarActions,
Spinner,
Text,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { DocumentArrowUp24Regular, Save24Regular, SignOut24Regular } from "@fluentui/react-icons";
import * as authService from "../../services/authService";
import { AuthRequiredError } from "../../services/apiClient";
import { getErrorMessage } from "../../services/errors";
import * as metadataService from "../../services/metadataService";
import { downloadVersion, getDownloadLink, getVersions } from "../../services/versioningService";
import { saveAndVersion, startVersioning } from "../../services/versionFlow";
import { VersionRecord } from "../../models/versionRecord";
import ConfirmVersionDialog from "./ConfirmVersionDialog";
import VersionHistoryPanel from "./VersionHistoryPanel";
/**
* Task pane หลัก — state machine ตาม Phase 5:
* checking → (ไม่มี token) signed-out
* → (มี token + ไม่ใช่ tracked document) untracked
* → (มี token + tracked document) tracking
*
* กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9)
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
*/
type Phase = "checking" | "signed-out" | "untracked" | "tracking";
/** FR-3 — ถามเอกสารทุก 30 วินาทีว่ามีแก้ไขที่ยังไม่ได้ save ไหม */
const UNSAVED_CHECK_INTERVAL_MS = 30_000;
const useStyles = makeStyles({ const useStyles = makeStyles({
root: { root: {
display: "flex",
flexDirection: "column",
minHeight: "100vh", minHeight: "100vh",
padding: tokens.spacingVerticalM,
gap: tokens.spacingVerticalS,
},
header: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
paddingBottom: tokens.spacingVerticalS,
borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
},
section: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: tokens.spacingVerticalS,
paddingTop: tokens.spacingVerticalM,
},
checking: {
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: tokens.spacingVerticalM,
paddingTop: tokens.spacingVerticalXL,
}, },
}); });
const App: React.FC<AppProps> = (props: AppProps) => { const App: React.FC = () => {
const styles = useStyles(); const styles = useStyles();
// The list items are static and won't change at runtime,
// so this should be an ordinary const, not a part of state. const [phase, setPhase] = React.useState<Phase>("checking");
const listItems: HeroListItem[] = [ const [bootError, setBootError] = React.useState<string | null>(null);
{ const [username, setUsername] = React.useState<string | null>(null);
icon: <Ribbon24Regular />,
primaryText: "Achieve more with Office integration", const [documentId, setDocumentId] = React.useState<string | null>(null);
const [versions, setVersions] = React.useState<VersionRecord[]>([]);
const [historyLoading, setHistoryLoading] = React.useState(false);
const [historyError, setHistoryError] = React.useState<string | null>(null);
const [signingIn, setSigningIn] = React.useState(false);
const [loginError, setLoginError] = React.useState<string | null>(null);
const [starting, setStarting] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [progressMessage, setProgressMessage] = React.useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [flowError, setFlowError] = React.useState<string | null>(null);
const [flowSuccess, setFlowSuccess] = React.useState<string | null>(null);
const [downloadingVersionId, setDownloadingVersionId] = React.useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false);
/** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */
const handleAuthRequired = React.useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) {
setPhase("signed-out");
setDocumentId(null);
setVersions([]);
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่");
return true;
}
return false;
}, []);
const refreshHistory = React.useCallback(
async (id: string) => {
setHistoryLoading(true);
setHistoryError(null);
try {
setVersions(await getVersions(id));
} catch (error) {
if (!handleAuthRequired(error)) {
setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ"));
}
} finally {
setHistoryLoading(false);
}
}, },
{ [handleAuthRequired]
icon: <LockOpen24Regular />, );
primaryText: "Unlock features and functionality",
}, /** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history */
{ const detect = React.useCallback(async () => {
icon: <DesignIdeas24Regular />, setPhase("checking");
primaryText: "Create and visualize like a pro", setBootError(null);
}, try {
]; const token = await authService.getAccessToken();
if (!token) {
setPhase("signed-out");
return;
}
authService.startAutoRefresh();
setUsername(authService.getUsername() ?? null);
const tracked = await metadataService.isTrackedDocument();
if (!tracked) {
setPhase("untracked");
return;
}
const properties = await metadataService.readTrackedProperties();
const id = properties.documentTrackingId;
if (!id) {
// mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked
setPhase("untracked");
return;
}
setDocumentId(id);
setPhase("tracking");
await refreshHistory(id);
} catch (error) {
setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ"));
}
}, [refreshHistory]);
React.useEffect(() => {
void detect();
}, [detect]);
/** FR-3 — poll `document.saved` เฉพาะตอน tracking */
React.useEffect(() => {
if (phase !== "tracking") {
setHasUnsavedChanges(false);
return;
}
const interval = setInterval(() => {
void (async () => {
try {
const unsaved = await Word.run(async (context) => {
const document = context.document;
document.load("saved");
await context.sync();
return !document.saved;
});
setHasUnsavedChanges(unsaved);
} catch {
// อ่านไม่ได้รอบนี้ก็ข้าม — banner เป็นแค่ soft reminder
}
})();
}, UNSAVED_CHECK_INTERVAL_MS);
return () => clearInterval(interval);
}, [phase]);
const handleSignIn = async () => {
setSigningIn(true);
setLoginError(null);
try {
const result = await authService.login();
if (result.ok) {
await detect();
} else {
setLoginError(result.error);
}
} catch (error) {
setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ"));
} finally {
setSigningIn(false);
}
};
const handleSignOut = () => {
authService.signOut();
setUsername(null);
setDocumentId(null);
setVersions([]);
setLoginError(null);
setPhase("signed-out");
};
/** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) */
const handleStartVersioning = async () => {
setStarting(true);
setFlowError(null);
setFlowSuccess(null);
try {
const result = await startVersioning(setProgressMessage);
setDocumentId(result.documentId);
setPhase("tracking");
setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`);
await refreshHistory(result.documentId);
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setStarting(false);
}
};
/** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload */
const handleSaveAndVersion = async () => {
if (!documentId) {
return;
}
setSaving(true);
setFlowError(null);
setFlowSuccess(null);
try {
const latestVersionNumber = versions[0]?.versionNumber ?? 0;
const result = await saveAndVersion(documentId, latestVersionNumber, setProgressMessage);
setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`);
await refreshHistory(documentId);
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ"));
}
} finally {
setProgressMessage(null);
setSaving(false);
}
};
/**
* FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน
* เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download
* แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย) · host ไหนไม่มี openBrowserWindow จะย้อนกลับไปใช้ blob
*/
const handleDownload = async (version: VersionRecord) => {
if (!documentId) {
return;
}
setDownloadingVersionId(version.versionId);
setFlowError(null);
try {
const link = await getDownloadLink(documentId, version.versionId);
if (typeof Office.context.ui.openBrowserWindow === "function") {
Office.context.ui.openBrowserWindow(link.url);
setFlowSuccess(`กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`);
} else {
const blob = await downloadVersion(documentId, version.versionId);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `document_v${version.versionNumber}.docx`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}
} catch (error) {
if (!handleAuthRequired(error)) {
setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ"));
}
} finally {
setDownloadingVersionId(null);
}
};
return ( return (
<div className={styles.root}> <div className={styles.root}>
<Header logo="assets/logo-filled.png" title={props.title} message="Welcome" /> <div className={styles.header}>
<HeroList message="Discover what this add-in can do for you today!" items={listItems} /> <Text weight="semibold">e-Court Documents Versioning</Text>
<TextInsertion insertText={insertText} /> {phase !== "signed-out" && phase !== "checking" && username ? (
<Button
size="small"
appearance="subtle"
icon={<SignOut24Regular />}
onClick={handleSignOut}
>
{username} · ออกจากระบบ
</Button>
) : null}
</div>
{phase === "checking" ? (
<div className={styles.checking}>
<Spinner label="กำลังตรวจสอบเอกสาร…" />
{bootError ? (
<MessageBar intent="error">
<MessageBarBody>{bootError}</MessageBarBody>
<MessageBarActions>
<Button size="small" appearance="outline" onClick={() => void detect()}>
ลองใหม่
</Button>
</MessageBarActions>
</MessageBar>
) : null}
</div>
) : null}
{phase === "signed-out" ? (
<div className={styles.section}>
<Text size={300}>เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก
</Text>
<Button appearance="primary" disabled={signingIn} onClick={() => void handleSignIn()}>
{signingIn ? "กำลังเข้าสู่ระบบ…" : "Sign in"}
</Button>
{loginError ? (
<MessageBar intent="warning">
<MessageBarBody>{loginError}</MessageBarBody>
</MessageBar>
) : null}
</div>
) : null}
{phase === "untracked" ? (
<div className={styles.section}>
<Text size={300}>เอกสารนี้ยังไม่ได้ติดตาม version</Text>
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1
(ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ)
</Text>
<Button
appearance="primary"
icon={<DocumentArrowUp24Regular />}
disabled={starting}
onClick={() => void handleStartVersioning()}
>
{starting ? "กำลังเริ่มติดตาม…" : "Start Versioning This Document"}
</Button>
{progressMessage ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Spinner size="tiny" />
<Text size={200}>{progressMessage}</Text>
</div>
) : null}
{flowError ? (
<MessageBar intent="error">
<MessageBarBody>{flowError}</MessageBarBody>
</MessageBar>
) : null}
</div>
) : null}
{phase === "tracking" ? (
<div className={styles.section}>
{hasUnsavedChanges ? (
<MessageBar intent="warning">
<MessageBarBody>
มีการแก้ไขที่ยังไม่ได้ version — กด Save &amp; Version เพื่อบันทึกเป็น version ใหม่
</MessageBarBody>
</MessageBar>
) : null}
{flowSuccess ? (
<MessageBar intent="success">
<MessageBarBody>{flowSuccess}</MessageBarBody>
</MessageBar>
) : null}
{flowError ? (
<MessageBar intent="error">
<MessageBarBody>{flowError}</MessageBarBody>
</MessageBar>
) : null}
<Button
appearance="primary"
size="large"
icon={<Save24Regular />}
disabled={saving}
onClick={() => setConfirmOpen(true)}
>
Save &amp; Version
</Button>
{saving && progressMessage ? (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Spinner size="tiny" />
<Text size={200}>{progressMessage}</Text>
</div>
) : null}
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
tracking id: {documentId?.slice(0, 8)}…
</Text>
<VersionHistoryPanel
versions={versions}
loading={historyLoading}
error={historyError}
downloadingVersionId={downloadingVersionId}
onDownload={(version) => void handleDownload(version)}
onRetry={() => documentId && void refreshHistory(documentId)}
/>
</div>
) : null}
<ConfirmVersionDialog
open={confirmOpen}
busy={saving}
onConfirm={() => void handleSaveAndVersion()}
onDismiss={() => setConfirmOpen(false)}
/>
</div> </div>
); );
}; };

View file

@ -0,0 +1,55 @@
import * as React from "react";
import {
Button,
Dialog,
DialogActions,
DialogBody,
DialogContent,
DialogSurface,
DialogTitle,
Spinner,
} from "@fluentui/react-components";
interface ConfirmVersionDialogProps {
open: boolean;
/** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่นทั้งสอง disable + แสดง spinner */
busy: boolean;
onConfirm: () => void;
onDismiss: () => void;
}
/** FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" */
export const ConfirmVersionDialog: React.FC<ConfirmVersionDialogProps> = ({
open,
busy,
onConfirm,
onDismiss,
}) => {
return (
<Dialog open={open} onOpenChange={(_event, data) => !data.open && !busy && onDismiss()} modalType="alert">
<DialogSurface>
<DialogBody>
<DialogTitle>สร้าง version ใหม่?</DialogTitle>
<DialogContent>
ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้
{busy ? (
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 12 }}>
<Spinner size="tiny" /> กำลังดำเนินการ…
</div>
) : null}
</DialogContent>
<DialogActions>
<Button appearance="secondary" disabled={busy} onClick={onDismiss}>
ยกเลิก
</Button>
<Button appearance="primary" disabled={busy} onClick={onConfirm}>
สร้าง version
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
);
};
export default ConfirmVersionDialog;

View file

@ -1,38 +0,0 @@
import * as React from "react";
import { Image, tokens, makeStyles } from "@fluentui/react-components";
export interface HeaderProps {
title: string;
logo: string;
message: string;
}
const useStyles = makeStyles({
welcome__header: {
display: "flex",
flexDirection: "column",
alignItems: "center",
paddingBottom: "30px",
paddingTop: "100px",
backgroundColor: tokens.colorNeutralBackground3,
},
message: {
fontSize: tokens.fontSizeHero900,
fontWeight: tokens.fontWeightRegular,
fontColor: tokens.colorNeutralBackgroundStatic,
},
});
const Header: React.FC<HeaderProps> = (props: HeaderProps) => {
const { title, logo, message } = props;
const styles = useStyles();
return (
<section className={styles.welcome__header}>
<Image width="90" height="90" src={logo} alt={title} />
<h1 className={styles.message}>{message}</h1>
</section>
);
};
export default Header;

View file

@ -1,62 +0,0 @@
import * as React from "react";
import { tokens, makeStyles } from "@fluentui/react-components";
export interface HeroListItem {
icon: React.JSX.Element;
primaryText: string;
}
export interface HeroListProps {
message: string;
items: HeroListItem[];
}
const useStyles = makeStyles({
list: {
marginTop: "20px",
},
listItem: {
paddingBottom: "20px",
display: "flex",
},
icon: {
marginRight: "10px",
},
itemText: {
fontSize: tokens.fontSizeBase300,
fontColor: tokens.colorNeutralBackgroundStatic,
},
welcome__main: {
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
},
message: {
fontSize: tokens.fontSizeBase500,
fontColor: tokens.colorNeutralBackgroundStatic,
fontWeight: tokens.fontWeightRegular,
paddingLeft: "10px",
paddingRight: "10px",
},
});
const HeroList: React.FC<HeroListProps> = (props: HeroListProps) => {
const { items, message } = props;
const styles = useStyles();
const listItems = items.map((item, index) => (
<li className={styles.listItem} key={index}>
<i className={styles.icon}>{item.icon}</i>
<span className={styles.itemText}>{item.primaryText}</span>
</li>
));
return (
<div className={styles.welcome__main}>
<h2 className={styles.message}>{message}</h2>
<ul className={styles.list}>{listItems}</ul>
</div>
);
};
export default HeroList;

View file

@ -1,57 +0,0 @@
import * as React from "react";
import { useState } from "react";
import { Button, Field, Textarea, tokens, makeStyles } from "@fluentui/react-components";
/* global HTMLTextAreaElement */
interface TextInsertionProps {
insertText: (_text: string) => void;
}
const useStyles = makeStyles({
instructions: {
fontWeight: tokens.fontWeightSemibold,
marginTop: "20px",
marginBottom: "10px",
},
textPromptAndInsertion: {
display: "flex",
flexDirection: "column",
alignItems: "center",
},
textAreaField: {
marginLeft: "20px",
marginTop: "30px",
marginBottom: "20px",
marginRight: "20px",
maxWidth: "50%",
},
});
const TextInsertion: React.FC<TextInsertionProps> = (props: TextInsertionProps) => {
const [text, setText] = useState<string>("Some text.");
const handleTextInsertion = async () => {
await props.insertText(text);
};
const handleTextChange = async (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(event.target.value);
};
const styles = useStyles();
return (
<div className={styles.textPromptAndInsertion}>
<Field className={styles.textAreaField} size="large" label="Enter text to be inserted into the document.">
<Textarea size="large" value={text} onChange={handleTextChange} />
</Field>
<Field className={styles.instructions}>Click the button to insert text.</Field>
<Button appearance="primary" disabled={false} size="large" onClick={handleTextInsertion}>
Insert text
</Button>
</div>
);
};
export default TextInsertion;

View file

@ -0,0 +1,128 @@
import * as React from "react";
import {
Badge,
Button,
MessageBar,
MessageBarBody,
Spinner,
Text,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { ArrowDownload24Regular, History24Regular } from "@fluentui/react-icons";
import { VersionRecord } from "../../models/versionRecord";
interface VersionHistoryPanelProps {
versions: VersionRecord[];
loading: boolean;
error: string | null;
/** versionId ของ version ที่กำลังดาวน์โหลดอยู่ (แสดง spinner ที่ปุ่ม) */
downloadingVersionId: string | null;
onDownload: (version: VersionRecord) => void;
onRetry: () => void;
}
const useStyles = makeStyles({
root: {
display: "flex",
flexDirection: "column",
gap: tokens.spacingVerticalS,
},
header: {
display: "flex",
alignItems: "center",
gap: tokens.spacingHorizontalXS,
marginTop: tokens.spacingVerticalL,
marginBottom: tokens.spacingVerticalXXS,
},
item: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
borderRadius: tokens.borderRadiusMedium,
":hover": {
backgroundColor: tokens.colorNeutralBackground1Hover,
},
},
itemInfo: {
display: "flex",
flexDirection: "column",
minWidth: 0,
},
metaRow: {
color: tokens.colorNeutralForeground3,
fontSize: tokens.fontSizeBase200,
},
});
function formatTimestamp(iso: string): string {
const date = new Date(iso);
return Number.isNaN(date.getTime())
? iso
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
}
/** FR-4 — แสดง version history (เลข version / เวลา / ผู้สร้าง) + ปุ่มดาวน์โหลด version นั้น */
export const VersionHistoryPanel: React.FC<VersionHistoryPanelProps> = ({
versions,
loading,
error,
downloadingVersionId,
onDownload,
onRetry,
}) => {
const styles = useStyles();
return (
<div className={styles.root}>
<div className={styles.header}>
<History24Regular />
<Text weight="semibold">Version history</Text>
</div>
{loading ? <Spinner size="small" label="กำลังโหลด…" /> : null}
{error ? (
<MessageBar intent="error">
<MessageBarBody>
{error}{" "}
<Button size="small" appearance="subtle" onClick={onRetry}>
ลองใหม่
</Button>
</MessageBarBody>
</MessageBar>
) : null}
{!loading && !error && versions.length === 0 ? (
<Text size={200} style={{ color: tokens.colorNeutralForeground3 }}>
ยังไม่มี version ในระบบ
</Text>
) : null}
{versions.map((version) => (
<div className={styles.item} key={version.versionId}>
<div className={styles.itemInfo}>
<Badge appearance="filled" color="brand">
v{version.versionNumber}
</Badge>
<span className={styles.metaRow}>{formatTimestamp(version.createdAt)}</span>
<span className={styles.metaRow}>โดย {version.createdByUserId}</span>
</div>
<Button
icon={<ArrowDownload24Regular />}
size="small"
appearance="subtle"
disabled={downloadingVersionId !== null}
onClick={() => onDownload(version)}
>
{downloadingVersionId === version.versionId ? "กำลังดาวน์โหลด…" : "ดาวน์โหลด"}
</Button>
</div>
))}
</div>
);
};
export default VersionHistoryPanel;

View file

@ -5,8 +5,6 @@ import { FluentProvider, webLightTheme } from "@fluentui/react-components";
/* global document, Office, module, require, HTMLElement */ /* global document, Office, module, require, HTMLElement */
const title = "Contoso Task Pane Add-in";
const rootElement: HTMLElement | null = document.getElementById("container"); const rootElement: HTMLElement | null = document.getElementById("container");
const root = rootElement ? createRoot(rootElement) : undefined; const root = rootElement ? createRoot(rootElement) : undefined;
@ -14,7 +12,7 @@ const root = rootElement ? createRoot(rootElement) : undefined;
Office.onReady(() => { Office.onReady(() => {
root?.render( root?.render(
<FluentProvider theme={webLightTheme}> <FluentProvider theme={webLightTheme}>
<App title={title} /> <App />
</FluentProvider> </FluentProvider>
); );
}); });

View file

@ -8,7 +8,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" /> <meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Contoso Task Pane Add-in</title> <title>e-Court Documents Versioning</title>
<!-- Office JavaScript API --> <!-- Office JavaScript API -->
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script> <script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>

View file

@ -1,14 +0,0 @@
/* global Word console */
export async function insertText(text: string) {
// Write text to the document.
try {
await Word.run(async (context) => {
let body = context.document.body;
body.insertParagraph(text, Word.InsertLocation.end);
await context.sync();
});
} catch (error) {
console.log("Error: " + error);
}
}

View file

@ -36,11 +36,17 @@ export function installFetchMock(
return fetchMock; return fetchMock;
} }
/** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` */ /** JWT ปลอมเพื่อ seed session — service อ่านแค่ claim `exp` / `sub` / `preferred_username` */
export function makeFakeJwt(expiresAtMs: number, subject = "user-1"): string { export function makeFakeJwt(
expiresAtMs: number,
subject = "user-1",
extraClaims: Record<string, unknown> = {}
): string {
const encode = (value: string): string => const encode = (value: string): string =>
btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const header = encode(JSON.stringify({ alg: "RS256", typ: "JWT" })); const header = encode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = encode(JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000) })); const payload = encode(
JSON.stringify({ sub: subject, exp: Math.floor(expiresAtMs / 1000), ...extraClaims })
);
return `${header}.${payload}.${encode("fake-signature")}`; return `${header}.${payload}.${encode("fake-signature")}`;
} }

View file

@ -23,6 +23,6 @@ if (typeof globalAny.TextEncoder === "undefined") {
globalAny.TextDecoder = util.TextDecoder; globalAny.TextDecoder = util.TextDecoder;
} }
globalAny.__KEYCLOAK_AUTHORITY__ = "http://localhost:8080/realms/word-versioning"; globalAny.__KEYCLOAK_AUTHORITY__ = "https://localhost:8443/realms/word-versioning";
globalAny.__KEYCLOAK_CLIENT_ID__ = "word-addin-officejs"; globalAny.__KEYCLOAK_CLIENT_ID__ = "word-addin-officejs";
globalAny.__API_BASE_URL__ = "http://localhost:5075"; globalAny.__API_BASE_URL__ = "https://localhost:7232";

View file

@ -25,6 +25,8 @@ export interface WordMockState {
/** part ทั้งหมดที่ "มีอยู่" ในเอกสารจริงๆ (หลัง delete/add) */ /** part ทั้งหมดที่ "มีอยู่" ในเอกสารจริงๆ (หลัง delete/add) */
xmlParts: Array<{ xml: string; namespace: string }>; xmlParts: Array<{ xml: string; namespace: string }>;
contentControls: Array<{ tag: string; text: string }>; contentControls: Array<{ tag: string; text: string }>;
/** จำนวนครั้งที่โค้ดเรียก context.document.save() */
saveCalls: number;
} }
export function installWordMock(options: WordMockOptions = {}): WordMockState { export function installWordMock(options: WordMockOptions = {}): WordMockState {
@ -34,6 +36,7 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
// part ที่ป้อนเข้า mock ถือว่าเป็นของ namespace ของเรา (จะเขียนทับ/delete โดย service) // part ที่ป้อนเข้า mock ถือว่าเป็นของ namespace ของเรา (จะเขียนทับ/delete โดย service)
xmlParts: (options.xmlParts ?? []).map((xml) => ({ xml, namespace: CUSTOM_XML_NAMESPACE })), xmlParts: (options.xmlParts ?? []).map((xml) => ({ xml, namespace: CUSTOM_XML_NAMESPACE })),
contentControls: (options.contentControls ?? []).map((control) => ({ ...control })), contentControls: (options.contentControls ?? []).map((control) => ({ ...control })),
saveCalls: 0,
}; };
const makeProperty = (key: string): unknown => { const makeProperty = (key: string): unknown => {
@ -93,6 +96,11 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
}, },
// ใน Word จริง customXmlParts อยู่บน context.document (Api set 1.4) // ใน Word จริง customXmlParts อยู่บน context.document (Api set 1.4)
customXmlParts, customXmlParts,
save: (_saveBehavior?: unknown) => {
state.saveCalls++;
},
/** FR-3 poll อ่านค่านี้ — default ไม่มีแก้ไขค้าง */
saved: true,
contentControls: { contentControls: {
get items() { get items() {
return state.contentControls.map((control) => ({ tag: control.tag, text: control.text })); return state.contentControls.map((control) => ({ tag: control.tag, text: control.text }));
@ -107,6 +115,7 @@ export function installWordMock(options: WordMockOptions = {}): WordMockState {
(globalThis as unknown as Record<string, unknown>).Word = { (globalThis as unknown as Record<string, unknown>).Word = {
run: async <T>(batch: (context: unknown) => Promise<T>): Promise<T> => run: async <T>(batch: (context: unknown) => Promise<T>): Promise<T> =>
batch(context as unknown as Word.RequestContext), batch(context as unknown as Word.RequestContext),
SaveBehavior: { save: "Save", prompt: "Prompt" },
} as unknown as typeof Word; } as unknown as typeof Word;
// กันชัดว่า mock ตรงกับ namespace ที่ service ใช้ // กันชัดว่า mock ตรงกับ namespace ที่ service ใช้

View file

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true,
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"esModuleInterop": true, "esModuleInterop": true,
"experimentalDecorators": true, "experimentalDecorators": true,

View file

@ -9,9 +9,12 @@ const urlDev = "https://localhost:3000/";
const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION const urlProd = "https://www.contoso.com/"; // CHANGE THIS TO YOUR PRODUCTION DEPLOYMENT LOCATION
// config ที่ inject เข้าโค้ดตอน build (อ่านจาก env — ห้าม hardcode ตามกติกา) — default คือ local dev // config ที่ inject เข้าโค้ดตอน build (อ่านจาก env — ห้าม hardcode ตามกติกา) — default คือ local dev
const keycloakAuthority = process.env.KEYCLOAK_AUTHORITY || "http://localhost:8080/realms/word-versioning"; // Keycloak ต้องเป็น HTTPS เสมอ: Office Dialog API ปฏิเสธ http:// ("The HTTP protocol is not supported")
const keycloakAuthority =
process.env.KEYCLOAK_AUTHORITY || "https://localhost:8443/realms/word-versioning";
const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "word-addin-officejs"; const keycloakClientId = process.env.KEYCLOAK_CLIENT_ID || "word-addin-officejs";
const apiBaseUrl = (process.env.API_BASE_URL || "http://localhost:5075").replace(/\/$/, ""); // backend ต้องเป็น HTTPS ด้วยเหมือนกัน — webview ของ Word บล็อก fetch http:// จากหน้า https (mixed content)
const apiBaseUrl = (process.env.API_BASE_URL || "https://localhost:7232").replace(/\/$/, "");
async function getHttpsOptions() { async function getHttpsOptions() {
const httpsOptions = await devCerts.getHttpsServerOptions(); const httpsOptions = await devCerts.getHttpsServerOptions();

View file

@ -400,6 +400,33 @@ public class DocumentsController(
return File(stream, DocxContentType, downloadName); return File(stream, DocxContentType, downloadName);
} }
/// <summary>FR-7 (ทางเลือก presigned URL อายุสั้น — spec ส่วน 9 อนุญาต)
/// ออก URL ให้ add-in เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง — webview ของ Word (โดยเฉพาะ WKWebView บน Mac)
/// ทิ้ง blob download แบบเงียบๆ จึงต้องให้เบราว์เซอร์จริงจัดการการดาวน์โหลดแทน</summary>
[HttpGet("{documentId:guid}/versions/{versionId:guid}/download-link")]
[ProducesResponseType(typeof(VersionDownloadLinkResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<VersionDownloadLinkResponse>> GetVersionDownloadLink(
Guid documentId, Guid versionId, CancellationToken ct)
{
var objectKey = await db.DocumentVersions
.AsNoTracking()
.Where(v => v.DocumentId == documentId && v.Id == versionId)
.Select(v => v.ObjectKey)
.FirstOrDefaultAsync(ct);
if (objectKey is null)
return NotFound($"ไม่พบ version {versionId} ของ document {documentId}");
var expirySeconds = Math.Clamp(minioOptions.Value.PresignedExpirySeconds, 30, 600);
var url = storage.GetPresignedGetUrl(minioOptions.Value.VersionsBucket, objectKey, TimeSpan.FromSeconds(expirySeconds));
logger.LogInformation(
"Version download link issued documentId={DocumentId} versionId={VersionId} userId={UserId} expirySeconds={ExpirySeconds}",
documentId, versionId, currentUser.UserId, expirySeconds);
return Ok(new VersionDownloadLinkResponse(url, DateTimeOffset.UtcNow + TimeSpan.FromSeconds(expirySeconds)));
}
/// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request /// <summary>ถอนการเปลี่ยน DocumentFieldValue ที่ stage ไว้กลับเป็นค่าก่อน request
/// — ใช้ตอน MinIO fail หลัง SaveChanges แล้ว (ไม่ให้ค่าล้ำหน้า version ที่ถูกถอน)</summary> /// — ใช้ตอน MinIO fail หลัง SaveChanges แล้ว (ไม่ให้ค่าล้ำหน้า version ที่ถูกถอน)</summary>
private static void RevertStagedFieldChanges( private static void RevertStagedFieldChanges(

View file

@ -39,3 +39,6 @@ public record VersionListItem(
string CreatedByUserId, string CreatedByUserId,
string ContentHash, string ContentHash,
object? Fields); object? Fields);
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt);

View file

@ -1,3 +1,5 @@
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
@ -69,6 +71,32 @@ builder.Services.AddCors(options => options.AddPolicy(corsPolicy, policy => poli
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod())); .AllowAnyMethod()));
// --- dev HTTPS -------------------------------------------------------------
// Word webview (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ ("Load failed")
// ทั้ง task pane เป็น https://localhost:3000 อยู่แล้ว — จึงต้องมี endpoint https ให้ API ด้วย
// ใช้ cert ชุดเดียวกับ Keycloak ใน docker-compose (office-addin-dev-certs — CA ถูก trust ในเครื่อง)
// เปิดเฉพาะตอน Environment=Development และเจอไฟล์ cert (ปิดผ่าน DevHttps:Enabled=false หรือรัน Production)
// หมายเหตุ: เมื่อ Listen ตรงนี้ URL จาก launchSettings/ASPNETCORE_URLS จะถูกแทนที่ — พอร์ตอ่านจาก config
var devHttpsEnabled = builder.Configuration.GetValue<bool?>("DevHttps:Enabled") ?? builder.Environment.IsDevelopment();
if (devHttpsEnabled)
{
var certDirectory = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".office-addin-dev-certs");
var certPath = builder.Configuration["DevHttps:CertificatePath"] ?? Path.Combine(certDirectory, "localhost.crt");
var keyPath = builder.Configuration["DevHttps:CertificateKeyPath"] ?? Path.Combine(certDirectory, "localhost.key");
if (File.Exists(certPath) && File.Exists(keyPath))
{
var certificate = LoadCertificateWithPrivateKey(certPath, keyPath);
var httpPort = builder.Configuration.GetValue("DevHttps:HttpPort", 5075);
var httpsPort = builder.Configuration.GetValue("DevHttps:HttpsPort", 7232);
builder.WebHost.ConfigureKestrel(options =>
{
options.ListenLocalhost(httpPort);
options.ListenLocalhost(httpsPort, listenOptions => listenOptions.UseHttps(certificate));
});
}
}
var app = builder.Build(); var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@ -83,3 +111,15 @@ app.UseAuthorization();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();
/// <summary>โหลด cert PEM + private key เป็น X509Certificate2 พร้อมใช้กับ Kestrel
/// (key ของ office-addin-dev-certs เป็น PKCS#1 ซึ่ง overload UseHttps(path, path) ไม่รับ — ต้องโหลดเอง)</summary>
static X509Certificate2 LoadCertificateWithPrivateKey(string certPath, string keyPath)
{
using var rsa = RSA.Create();
rsa.ImportFromPem(File.ReadAllText(keyPath)); // รองรับทั้ง PKCS#1 และ PKCS#8
var leaf = X509Certificate2.CreateFromPem(File.ReadAllText(certPath));
using var paired = leaf.CopyWithPrivateKey(rsa);
// macOS: SslStream ใช้ key ได้ต่อเมื่อ cert ถูก export กลับมาเป็น PFX แบบ in-memory
return new X509Certificate2(paired.Export(X509ContentType.Pfx));
}

View file

@ -18,4 +18,7 @@ public class MinioOptions
public string VersionsBucket { get; set; } = "docx-versions"; public string VersionsBucket { get; set; } = "docx-versions";
public string TemplatesBucket { get; set; } = "docx-templates"; public string TemplatesBucket { get; set; } = "docx-templates";
/// <summary>อายุของ presigned download URL ที่ออกให้เบราว์เซอร์ (วินาที) — สั้นๆ ตาม spec</summary>
public int PresignedExpirySeconds { get; set; } = 120;
} }

View file

@ -12,17 +12,23 @@ public interface IObjectStorageService
/// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary> /// <summary>ดึง object กลับมาเป็น stream (proxy ต่อให้ client) — stream นี้ dispose แล้วจะปิด S3 response ให้ด้วย</summary>
Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default); Task<Stream> GetStreamAsync(string bucket, string key, CancellationToken ct = default);
/// <summary>สร้าง presigned GET URL อายุสั้น — ให้เปิดในเบราว์เซอร์ของผู้ใช้โดยตรง
/// (webview ของ Word ทิ้ง blob download แบบเงียบๆ — spec อนุญาต presigned URL อายุสั้นสำหรับดาวน์โหลด)</summary>
string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry);
} }
/// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary> /// <summary>Wrapper เรียก MinIO ผ่าน AWSSDK.S3 (S3-compatible API)</summary>
public class ObjectStorageService : IObjectStorageService public class ObjectStorageService : IObjectStorageService
{ {
private readonly IAmazonS3 _s3; private readonly IAmazonS3 _s3;
private readonly bool _useHttps;
private readonly ILogger<ObjectStorageService> _logger; private readonly ILogger<ObjectStorageService> _logger;
public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger) public ObjectStorageService(IOptions<MinioOptions> options, ILogger<ObjectStorageService> logger)
{ {
var opt = options.Value; var opt = options.Value;
_useHttps = opt.UseSSL;
var config = new AmazonS3Config var config = new AmazonS3Config
{ {
ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint, ServiceURL = (opt.UseSSL ? "https://" : "http://") + opt.Endpoint,
@ -70,6 +76,27 @@ public class ObjectStorageService : IObjectStorageService
return new S3ResponseStream(response); return new S3ResponseStream(response);
} }
public string GetPresignedGetUrl(string bucket, string key, TimeSpan expiry)
{
var request = new GetPreSignedUrlRequest
{
BucketName = bucket,
Key = key,
Verb = HttpVerb.GET,
Expires = DateTime.UtcNow + expiry,
};
// GetPreSignedURL เป็น sync (เซ็น SigV4 ฝั่ง client ไม่ยิง network)
var url = _s3.GetPreSignedURL(request);
// AWSSDK v4 บังคับ https ใน presigned URL เสมอ (ไม่สน UseHttp) — สลับ scheme เองตาม config ได้
// เพราะ SigV4 เซ็นค่า host header (host:port) ไม่รวม scheme → signature ยังถูกต้อง
if (!_useHttps && url.StartsWith("https://", StringComparison.Ordinal))
{
url = "http://" + url["https://".Length..];
}
_logger.LogInformation("Issued presigned GET {Bucket}/{Key} expiry={Expiry}s", bucket, key, expiry.TotalSeconds);
return url;
}
/// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน /// <summary>Stream ที่ครอบ GetObjectResponse — dispose stream = dispose response ทั้งอัน
/// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary> /// (ไม่งั้น controller ต้องรับ response มาด้วยแล้วจัดการ lifetime เอง ยุ่ง)</summary>
private sealed class S3ResponseStream(GetObjectResponse response) : Stream private sealed class S3ResponseStream(GetObjectResponse response) : Stream

View file

@ -15,5 +15,12 @@
"MinIO": { "MinIO": {
"AccessKey": "wordversioning-minio", "AccessKey": "wordversioning-minio",
"SecretKey": "dev-local-pass" "SecretKey": "dev-local-pass"
},
"DevHttps": {
"//": "endpoint https ของ dev (Word webview บล็อก http จากหน้า https) — cert จาก ~/.office-addin-dev-certs",
"Enabled": true,
"HttpPort": 5075,
"HttpsPort": 7232
} }
} }

View file

@ -21,7 +21,7 @@
}, },
"Authentication": { "Authentication": {
"Authority": "http://localhost:8080/realms/word-versioning", "Authority": "https://localhost:8443/realms/word-versioning",
"ValidAudiences": [ "word-addin-officejs", "word-versioning-web" ] "ValidAudiences": [ "word-addin-officejs", "word-versioning-web" ]
}, },

View file

@ -77,10 +77,16 @@ services:
environment: environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME} KC_BOOTSTRAP_ADMIN_USERNAME: ${KC_BOOTSTRAP_ADMIN_USERNAME}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD} KC_BOOTSTRAP_ADMIN_PASSWORD: ${KC_BOOTSTRAP_ADMIN_PASSWORD}
# HTTPS ด้วย cert ของ office-addin-dev-certs (CA ถูก trust ใน keychain แล้ว)
# จำเป็นเพราะ Office Dialog API บังคับ HTTPS สำหรับหน้า login — http:// โดนปฏิเสธ
KC_HTTPS_CERTIFICATE_FILE: /certs/localhost.crt
KC_HTTPS_CERTIFICATE_KEY_FILE: /certs/localhost.key
ports: ports:
- "8080:8080" # admin console + OIDC endpoints - "8443:8443" # OIDC endpoints + admin console (HTTPS — ใช้ตัวนี้)
# 8080 (http) เปิดไว้เฉพาะใน network ของ compose ให้ service `api` (profile) ใช้ภายใน
volumes: volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro - ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
- ${HOME}/.office-addin-dev-certs:/certs:ro
# --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) ----- # --- Backend API (ยังไม่เปิดใช้ — รอ Phase 2 สร้าง Dockerfile) -----
# รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:9000 # รันฝั่ง host ด้วย `dotnet run` ได้เลย โดยชี้ config ไปที่ localhost:5432 / localhost:9000

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8) # Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md) > แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: Phase 0–4 เสร็จ (scaffold + infra + backend ครบ + add-in services layer) — ต่อไป: phase 5 (add-in UI) หรือ phase 6 (web) ทำขนานกันได้ > สถานะ: Phase 0–5 เสร็จ (backend ครบ + add-in ครบทั้ง services และ UI — เหลือตรวจ sideload บน Word จริง) — ต่อไป: phase 6 (web portal)
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync) > วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
--- ---
@ -51,24 +51,32 @@
/word-addin /word-addin
/addin # Phase 0, 4, 5 /addin # Phase 0, 4, 5
manifest.xml manifest.xml
eslint.config.mjs # office-addin-lint ใช้ไฟล์นี้แทน config ของมัน (ปิด rule ที่ crash)
jest.config.js
/src /src
/taskpane /taskpane
taskpane.html taskpane.html
taskpane.tsx index.tsx # Office.onReady → FluentProvider + App
auth-callback.html auth-callback.html/.ts # redirect target ของ Keycloak (รันใน Office Dialog)
/components /components
App.tsx # state machine: checking → signed-out → untracked → tracking
VersionHistoryPanel.tsx VersionHistoryPanel.tsx
ConfirmVersionDialog.tsx ConfirmVersionDialog.tsx
/services /services
authService.ts # PKCE + Dialog API + token in-memory + silent refresh
metadataService.ts # custom properties + custom XML part + content controls metadataService.ts # custom properties + custom XML part + content controls
versioningService.ts fileService.ts # getFileAsync → slices → Blob (normalize slice.data ทุกรูปแบบ)
fileService.ts apiClient.ts # fetch + Bearer + retry-on-401 ครั้งเดียว
authService.ts versioningService.ts # uploadVersion / getVersions / download + download-link
apiClient.ts versionFlow.ts # orchestration ของ Start Versioning / Save & Version (7 ขั้น)
errors.ts
/models /models
versionRecord.ts versionRecord.ts
/test # wordMock / httpMock / setup (jsdom polyfills)
package.json package.json
webpack.config.js webpack.config.js # DefinePlugin: KEYCLOAK_AUTHORITY / API_BASE_URL จาก env
/scripts
dev-token.py # เดิน PKCE จริงได้ token ทดสอบ backend (admin-cli ไม่มี aud)
/backend # Phase 0, 2, 3 /backend # Phase 0, 2, 3
/WordVersioning.Api /WordVersioning.Api
Program.cs Program.cs
@ -344,13 +352,13 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
**เป้า:** UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4) **เป้า:** UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4)
- [ ] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking` - [x] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking`
- [ ] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID` - [x] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID`
- ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**) - ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**)
- ไม่ตรง → ปุ่ม **"Start Versioning This Document"** - ไม่ตรง → ปุ่ม **"Start Versioning This Document"**
- [ ] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking` - [x] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
- [ ] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" - [x] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
- [ ] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:** - [x] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
1. เปิด confirm dialog 1. เปิด confirm dialog
2. Stamp properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp` 2. Stamp properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent) 3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
@ -359,13 +367,29 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
6. `POST /api/documents/{documentId}/versions` — แนบ `fields` ด้วย 6. `POST /api/documents/{documentId}/versions` — แนบ `fields` ด้วย
7. แสดงผล + refresh history 7. แสดงผล + refresh history
- ทุก step มี loading state + error message + **ห้ามปล่อย unhandled rejection** - ทุก step มี loading state + error message + **ห้ามปล่อย unhandled rejection**
- [ ] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download`) - [x] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download`)
- [ ] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร) - [x] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
- [ ] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9) - [x] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
- [ ] Fluent UI components ให้หน้าตาเข้ากับ Office - [x] Fluent UI components ให้หน้าตาเข้ากับ Office
หมายเหตุจากการทำจริง (2026-08-19):
- **state machine อยู่ที่ `src/taskpane/components/App.tsx`** (yo office ให้โครง index.tsx + App.tsx — ไม่มี taskpane.tsx ตามชื่อในแผน) และ orchestration ของ Start Versioning / Save & Version แยกเป็น `services/versionFlow.ts` เพื่อ unit test ได้ (mock ทั้ง Word/Office/fetch) — `npm test` ขยายเป็น 45 ตัว
- **`LastVersionId` ในไฟล์ = `clientVersionId`** (GUID ที่ client generate) ไม่ใช่ versionId ของ backend — เพราะต้อง stamp ลง properties *ก่อน* `document.save()` จึงจะไปอยู่ในไฟล์ที่อัปโหลด (backend versionId ได้มาหลังไฟล์ถูก capture ไปแล้ว) · ส่วน entry ใน XML part เดาเลข version จาก history ล่าสุด+1 ก่อน upload ถ้า backend ตอบเลขต่าง (มี client อื่นแทรก) จะเขียน XML part แก้ให้ตรงแบบ best-effort
- **เปิด `strict` ใน tsconfig ของ addin** — พบว่า tsconfig ของ scaffold ปิด strictNullChecks ซึ่งทำให้ boolean-literal discriminated union (`{ok:true} | {ok:false; error}`) **narrow ไม่ได้** (compile error) — เปิดแล้วมี error เพิ่มแค่ 4 จุดใน test (แก้แล้ว)
- ชื่อไฟล์ตอน upload ได้จาก `Office.context.document.url` (desktop มักมี path จริง) fallback `{documentId}.docx` (Word Online อาจไม่มี URL)
- FR-3: poll ทุก 30 วิ **เฉพาะ phase `tracking`** อ่าน `context.document.saved` — fail รอบใดข้ามรอบนั้น (banner เป็น soft reminder เท่านั้น)
- ดาวน์โหลด version: fetch เป็น blob (ต้องแนบ Bearer) → `URL.createObjectURL` + anchor `download` — ชื่อไฟล์ `document_v{N}.docx`
- ลบ scaffold demo ออก (Header/HeroList/TextInsertion/taskpane.ts) — task pane เป็นหน้าจริงของระบบแล้ว
- ⚠️ **DoD ส่วน sideload ต้องตรวจด้วยมือใน Word จริง** (Claude รัน Word ไม่ได้) — ขั้นตอน: รัน infra + backend + `npm start` แล้ว sideload manifest.xml บน Word Desktop/Online แล้วไล่ checklist AC-1, 2, 4–8, 13 (ส่วนที่ automate ได้คือ build/test/lint/validate ซึ่งผ่านทั้งหมดแล้ว)
- 🩹 **แก้จาก sideload test จริงรอบแรก:** กด Sign in แล้วโดน "The HTTP protocol is not supported. Use HTTPS instead" — **Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์** ทั้งที่ Keycloak dev เดิมรัน http://localhost:8080 → แก้โดยให้ Keycloak เสิร์ฟ **HTTPS ที่ 8443** ด้วย cert ของ `office-addin-dev-certs` (mount `${HOME}/.office-addin-dev-certs` เข้า container + `KC_HTTPS_CERTIFICATE_FILE/KEY_FILE`) · 8080 เหลือไว้เฉพาะภายใน network ของ compose (ให้ service `api` ใช้) · ค่าที่ตามไปเปลี่ยน: backend `Authentication:Authority` → `https://localhost:8443/realms/word-versioning`, add-in webpack default `KEYCLOAK_AUTHORITY` → เดียวกัน, README/.env.example/test constants ทั้งหมด · ตรวจจริงครบ: discovery ผ่าน (issuer = https…8443), PKCE flow จริงได้ token ที่มี `aud` ถูกต้อง, backend ตอบ 200 · **ผลพลอยได้:** เจอว่าตัวอย่าง curl admin-cli เดิมเรียก backend ไม่ผ่าน (token ไม่มี `aud`) → เพิ่ม `scripts/dev-token.py` (เดิน PKCE จริง ได้ token ใช้ทดสอบ backend/Swagger) และแก้ README ให้ใช้แทน
- 🩹 **แก้จาก sideload test จริงรอบสี่:** กดดาวน์โหลด version แล้ว**ไฟล์ไม่ไปไหนเลย** (ค้น ~/Downloads + Word container + Spotlight ไม่เจอ) — **WKWebView ของ Word ทิ้ง blob download (`a[download]` + object URL) แบบเงียบๆ** → เปลี่ยนวิธีเป็น "ขอ presigned URL อายุสั้นแล้วเปิดในเบราว์เซอร์ของเครื่อง": backend เพิ่ม `GET .../download-link` (MinIO presigned GET · อายุ config `MinIO:PresignedExpirySeconds` default 120s clamp 30–600 — spec ส่วน 9 อนุญาต presigned URL อายุสั้น) · add-in ใช้ `Office.context.ui.openBrowserWindow` (manifest เพิ่ม `OpenBrowserWindowApi 1.1`) โดยมี blob เดิมเป็น fallback บน host ที่ไม่มี API นี้ · จุดเจาะจง: **AWSSDK.S3 v4 บังคับ https ใน presigned URL เสมอ** (พิสูจน์ด้วยโปรเจกต์ทดสอบแยก — UseHttp ไม่ช่วย) แต่ SigV4 เซ็นค่า host header ไม่รวม scheme จึงสลับ https→http หลังเซ็นตาม config `UseSSL` ได้ · ตรวจจริง: upload → ขอ link → curl presigned URL ได้ไบต์ตรงเป๊ะ ✓ · อื่นๆ รอบนี้: **ลบ `manifest.json` ทิ้ง** (unified manifest ฝั่ง Outlook จาก scaffold — ไม่ได้ใช้ แก้แล้วไม่มีผลกับ Word) และเปลี่ยนชื่อเป็น **e-Court Documents Versioning** ทั้งใน manifest.xml (DisplayName/group/ปุ่ม) และหัว task pane · ⚠️ **อย่า `cp` ทับ manifest ใน wef folder** — office-addin-debugging สร้าง symlink ไว้ การ cp ทับทำให้กลายเป็นไฟล์ธรรมดา แล้ว `npm start` ครั้งถัดไปจะ EEXIST — ให้แก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word
- 🩹 **แก้จาก sideload test จริงรอบสาม:** เข้า state tracking ได้แล้ว แต่ทุก API call โดน "Load failed" — **WKWebView ของ Word บน Mac บล็อก fetch จากหน้า https ไป http แบบเงียบๆ (mixed content/ATS)**: task pane เป็น `https://localhost:3000` ส่วน API เดิมเป็น `http://localhost:5075` (login ผ่านเพราะ Keycloak เป็น https แล้ว · CORS ตรวจแล้วสมบูรณ์ ไม่ใช่ต้นเหตุ) → แก้ด้วยวิธีเดียวกับ Keycloak: **backend เปิด endpoint `https://localhost:7232` ด้วย dev cert ชุดเดียวกัน** — `Program.cs` เพิ่ม `ConfigureKestrel` (เปิดเฉพาะ Development + เจอ cert, config `DevHttps:*` ใน appsettings.Development.json, Listen ทั้ง 5075/7232 แทน URL จาก launchSettings) · จุดเจาะจง: key ของ office-addin-dev-certs เป็น **PKCS#1** ซึ่ง `UseHttps(cert, key)` overload ไม่รับ → โหลดเองด้วย `RSA.ImportFromPem` + `CopyWithPrivateKey` + export PFX in-memory (macOS ต้องทำแบบนี้ SslStream ถึงใช้ key ได้) · add-in `API_BASE_URL` default → `https://localhost:7232` · ตรวจจริง: รัน temp instance แล้ว curl ผ่าน https ด้วย dev-token.py ได้ 200 · **สรุปกติกา dev: ทุก origin ที่หน้า https จะเรียก (Keycloak 8443, backend 7232) ต้องเป็น https ด้วย cert จาก office-addin-dev-certs ทั้งหมด**
- 🩹 **แก้จาก sideload test จริงรอบสอง:** login ผ่านแล้ว กด Start Versioning โดน "รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)" — **host แต่ละตัวส่ง `slice.data` กลับคนละรูป** (เอกสารระบุลอยๆ ว่า "byte array"): Word Win = ArrayBuffer/view แต่ **Word Mac ส่งมาเป็นอย่างอื่น** (base64 string / number[]) → `sliceDataToBytes()` ใน fileService รองรับครบทั้ง 4 รูปแบบ + เติม test ครบทุกรูป (47 ตัว) · อีกเรื่องที่เจอ: restart `npm start` ไม่ได้แทน dev-server ตัวเก่า (port 3000 ถูกถือครอง → ตัวเก่ายัง serve bundle เก่า) — ต้อง kill ตัวเก่าก่อนเสมอ · และ webpack **ไม่ restart เองเมื่อ webpack.config.js เปลี่ยน** (DefinePlugin ค่าเก่าติดไปกับ bundle เก่า) — แก้ config แล้วต้อง restart dev-server ทุกครั้ง
**DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12) **DoD:** sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)
> 🟡 implement + automated checks ผ่านหมด (2026-08-19): `npm run build` compiled successfully, `npm test` 47/47 (versionFlow + fileService ครบทุกรูปแบบ slice), `npm run lint` + `npm run validate` ผ่าน · **sideload ทดสอบจริงบน Word for Mac แล้วบางส่วน**: login ผ่าน dialog ✓ · ตรวจจับ + เข้า state tracking ✓ · Start Versioning / Save & Version ✓ — เจอปัญหาตกมุม 4 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · **ยังเหลือ**: ยืนยันการดาวน์โหลดผ่าน presigned link หลังแก้รอบล่าสุด, ไล่ AC เต็มชุด (โดยเฉพาะ AC-9/10/13) และทดสอบบน Word Online/Windows
--- ---
## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)* ## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)*

114
scripts/dev-token.py Executable file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""ขอ access token แบบ PKCE (authorization code flow) จาก Keycloak dev — สำหรับทดสอบ backend ผ่าน curl/Swagger.
ทำไมต้องมีสคริปต์นี้: ทุก client ใน realm ปิด direct grants บังคับ PKCE ตาม spec
ส่วน token ของ `admin-cli` ไม่มี claim `aud` จึงเรียก backend ไม่ผ่าน (401)
วิธีใช้:
python3 scripts/dev-token.py # ปริ้น access token
python3 scripts/dev-token.py --clip # คัดลอกเข้า clipboard (macOS)
แก้ user/password ผ่าน env: DEV_TOKEN_USERNAME / DEV_TOKEN_PASSWORD (default testuser/Test1234!)
"""
import argparse
import base64
import hashlib
import http.cookiejar
import json
import os
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
REALM = "https://localhost:8443/realms/word-versioning"
CLIENT_ID = "word-versioning-web"
REDIRECT_URI = "http://localhost:5173/callback" # ไม่ต้องมี server จริง — แค่ต้องตรงกับที่ลงทะเบียนไว้
CA_FILE = os.path.expanduser("~/.office-addin-dev-certs/ca.crt")
USERNAME = os.environ.get("DEV_TOKEN_USERNAME", "testuser")
PASSWORD = os.environ.get("DEV_TOKEN_PASSWORD", "Test1234!")
class NoRedirect(urllib.request.HTTPRedirectHandler):
"""หยุดที่ 302 เพื่อเก็บ authorization code จาก Location"""
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ARG002
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--clip", action="store_true", help="คัดลอก token เข้า clipboard (pbcopy)")
args = parser.parse_args()
verifier = base64.urlsafe_b64encode(os.urandom(48)).rstrip(b"=").decode()
challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
ctx = ssl.create_default_context(cafile=CA_FILE)
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()),
NoRedirect,
urllib.request.HTTPSHandler(context=ctx),
)
auth_url = f"{REALM}/protocol/openid-connect/auth?" + urllib.parse.urlencode(
{
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": "openid",
"state": "dev-token",
"code_challenge": challenge,
"code_challenge_method": "S256",
}
)
login_page = opener.open(auth_url).read().decode()
action = re.search(r'action="([^"]+)"', login_page)
if not action:
print("ไม่พบ login form — ตรวจ Keycloak ที่ https://localhost:8443", file=sys.stderr)
return 1
credentials = urllib.parse.urlencode(
{"username": USERNAME, "password": PASSWORD, "credentialId": ""}
).encode()
try:
opener.open(urllib.request.Request(action.group(1).replace("&amp;", "&"), data=credentials))
print("login ไม่ redirect — ตรวจ username/password", file=sys.stderr)
return 1
except urllib.error.HTTPError as error:
location = error.headers.get("Location", "")
code = urllib.parse.parse_qs(urllib.parse.urlparse(location).query).get("code", [None])[0]
if not code:
print("ไม่ได้รับ authorization code (redirect ผิดหรือ login พลาด)", file=sys.stderr)
return 1
token_body = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": verifier,
}
).encode()
token_response = json.loads(
opener.open(urllib.request.Request(f"{REALM}/protocol/openid-connect/token", data=token_body)).read()
)
token = token_response["access_token"]
if args.clip:
import subprocess
subprocess.run("pbcopy", input=token.encode(), check=True)
print(f"คัดลอก access token ({len(token)} chars) เข้า clipboard แล้ว")
else:
print(token)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -1,5 +1,6 @@
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend) # Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
> ฉบับแก้ไข 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` > ฉบับแก้ไข 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. ภาพรวม ## 1. ภาพรวม
@ -31,6 +32,13 @@
- **Custom Document Properties เสถียรกว่าและ cross-platform ดีกว่า** → ใช้เป็น primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้หรือไม่ - **Custom Document Properties เสถียรกว่าและ cross-platform ดีกว่า** → ใช้เป็น primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้หรือไม่
- Manifest ต้อง support ทั้ง requirement set ที่ `document.customXmlParts`, `document.properties.customProperties`, และ `document.save()` (WordApi 1.5+) ใช้งานได้ - 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. เทคโนโลยี ## 3. เทคโนโลยี
@ -166,9 +174,11 @@ GET /api/documents/{documentId}/versions
### FR-7: ดาวน์โหลด version ที่ระบุ ### FR-7: ดาวน์โหลด version ที่ระบุ
``` ```
GET /api/documents/{documentId}/versions/{versionId}/download GET /api/documents/{documentId}/versions/{versionId}/download # proxy stream (curl/Swagger)
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (add-in ใช้อันนี้)
``` ```
- Stream ไฟล์จาก MinIO กลับไป พร้อม content-type ที่ถูกต้อง (ใช้ presigned URL หรือ proxy stream ผ่าน backend ก็ได้ — แนะนำ proxy stream เพื่อคุม auth ที่ endpoint เดียว ไม่ต้อง expose MinIO ตรงๆ ให้ client) - `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) ### FR-8: Auth (Keycloak / OpenID Connect)
@ -302,7 +312,7 @@ Custom Document Properties:
|---|---| |---|---|
| `SavedByAddIn` | GUID คงที่ของ add-in — ค่าที่ปักหมุด: `9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D` (backend: `Services/AddInMetadata.AddInSignatureGuid`, add-in: `ADDIN_SIGNATURE_GUID` ต้องเป็นค่านี้เป๊ะ) | | `SavedByAddIn` | GUID คงที่ของ add-in — ค่าที่ปักหมุด: `9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D` (backend: `Services/AddInMetadata.AddInSignatureGuid`, add-in: `ADDIN_SIGNATURE_GUID` ต้องเป็นค่านี้เป๊ะ) |
| `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) | | `DocumentTrackingId` | GUID เฉพาะของเอกสาร (ผูกกับ `documentId` ฝั่ง backend) |
| `LastVersionId` | GUID ของ version ล่าสุด | | `LastVersionId` | GUID ของ version ล่าสุด — คือ `clientVersionId` ที่ client generate (ต้อง stamp ลงไฟล์*ก่อน* save/upload จึงจะติดไปกับ snapshot; versionId จริงของ backend ได้มาหลังไฟล์ถูก capture ไปแล้ว — backend คือ source of truth ของ id จริง) |
| `LastVersionTimestamp` | ISO-8601 UTC timestamp | | `LastVersionTimestamp` | ISO-8601 UTC timestamp |
Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth): Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact history เสริม (ไม่ใช่ source of truth, backend คือ source of truth):
@ -333,6 +343,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
- OpenXML replace ต้องแก้ที่ **run level ใน SDT** เพื่อรักษา formatting ของ template — ห้ามทับทั้ง paragraph - OpenXML replace ต้องแก้ที่ **run level ใน SDT** เพื่อรักษา formatting ของ template — ห้ามทับทั้ง paragraph
- Web Portal ทุกหน้ารองรับ **light/dark theme** (toggle + persist + default ตาม `prefers-color-scheme`) - 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 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`) - Backend CORS ต้องอนุญาตทั้ง origin ของ add-in (`https://localhost:3000`) และ Web Portal (`http://localhost:5173`)
--- ---