eCourt-Doc-Edit/docs/plans/officejs-implementation-plan.md
Suphonchai Phoonsawat 41573b3c4f phase 0
2026-08-18 12:52:40 +07:00

438 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: รอเริ่ม implement (repo ยังไม่มีโค้ด)
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
---
## 1. ภาพรวม
ระบบ 3 ส่วนที่ทำงานร่วมกัน:
| ส่วน | เทคโนโลยี | หน้าที่ |
|---|---|---|
| **Add-in** (`/addin`) | Office.js, TypeScript, React, Fluent UI, webpack (scaffold จาก `yo office`) | Task pane ใน Word Desktop (Win/Mac) + Word Online — ตรวจจับ tracked document, ปุ่ม Save & Version, แสดง version history, จัดการ auth, extract fields จากเอกสาร |
| **Backend** (`/backend/WordVersioning.Api`) | ASP.NET Core 8, EF Core, **PostgreSQL**, AWSSDK.S3 → **MinIO**, DocumentFormat.OpenXml | เก็บ version metadata + ไฟล์ .docx ใน MinIO, validate JWT, ระบบ template + generate เอกสาร + field sync |
| **Web Portal** (`/web`) | **Nuxt.js + TailwindCSS** (รองรับ light/dark theme) | เว็บแยกสำหรับ (1) จัดการ template (2) กรอก form สร้างเอกสารจาก template → ได้ .docx ที่เป็น **version 1** พร้อม metadata แล้ว |
| **Auth** | **Keycloak** (OIDC) | Public clients + PKCE (ทั้ง add-in และเว็บ), backend validate ผ่าน JWKS |
### ข้อตกลงที่ยืนยันแล้ว
- **Database = PostgreSQL** (เลือกจาก spec ที่เปิดไว้ให้เลือก SQL Server หรือ PostgreSQL) — ใช้ provider `Npgsql.EntityFrameworkCore.PostgreSQL`
- **Scope** = Office.js add-in + .NET backend + Web Portal ตามเอกสารนี้ (spec มีอ้างถึง `spec-vsto.md` แต่ไฟล์ยังไม่มี — แผนนี้เพียง pre-register Keycloak client `word-addin-vsto` ไว้ตามที่ spec กำหนด ไม่มีงาน VSTO)
- หน้าจอสร้างเอกสารครั้งแรก = **เว็บแยก (standalone)** — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที
- Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template)
- Field sync = **เฉพาะ fields จาก template** (ผูกกับ content controls) ไม่เก็บ full text
- **FR-9 ถึง FR-11 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ได้รับการ update เป็นฉบับ v2 แล้ว (ครอบ FR-1–11 + AC-1–14) ให้ถือ spec เป็น source of truth ของ requirements
---
## 2. กติกาพื้นฐานที่ทุก phase ต้องยึด
กติกาเหล่านี้เป็นข้อจำกัดของแพลตฟอร์ม ห้ามละเมิดไม่ว่าจะ implement phase ไหน:
1. **ห้ามออกแบบ flow ที่พึ่งการดัก native save** (Ctrl+S / File>Save) — Office.js ไม่มี before-save event ที่ cancel ได้ ต้องใช้ explicit action (ปุ่มใน task pane / ribbon) เท่านั้น
2. **Custom Document Properties = primary flag** สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้
3. **Custom XML Part = secondary storage เท่านั้น** — แก้ไม่ได้ตรงๆ ต้อง `delete()` แล้ว `add()` ใหม่ทุกครั้ง และมี known bug ที่อาจไม่ persist บน Mac บาง build → ระบบต้องทำงานถูกต้องแม้ XML part ล้มเหลวแบบ silent
4. **ห้าม redirect iframe ของ task pane ไป Keycloak ตรงๆ** (โดน `X-Frame-Options`/CSP บล็อก) — ต้องใช้ Office Dialog API (`displayDialogAsync`) + `messageParent`; ส่วน `/web` เป็น top-level page ใช้ OIDC redirect ปกติได้
5. **Token เก็บใน memory เท่านั้น** — ห้าม localStorage; ถ้าจำเป็นต้อง persist ใช้ `sessionStorage` อายุสั้น (ข้อยกเว้น: theme preference ของ `/web` เก็บ localStorage ได้เพราะไม่ใช่ข้อมูลรับรอง)
6. **Backend คือ source of truth** ของ version history และ field values เสมอ
7. **Config ทุกอย่าง (MinIO, Keycloak, DB) แยกเป็น env/`appsettings.json`** ห้าม hardcode
8. **ทุก Office.js call ต้อง catch** และแสดง error ที่เข้าใจง่ายใน task pane — ห้ามปล่อย unhandled promise rejection
9. **Field key = content control (SDT) tag** ต้อง unique ต่อ template — ใช้ tag เดียวกันทั้งตอน generate (backend), ตอน extract (add-in) และตอน sync (DB)
10. **Generation + metadata stamping ทำฝั่ง backend เท่านั้น** (OpenXML SDK) เพื่อให้ v1 + custom properties เกิดพร้อมกันแบบ atomic — ไฟล์ที่โหลดจากเว็บถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก ไม่ต้องพึ่ง client ตั้งค่าใดๆ
11. **⚠️ Port ห้ามชนกัน:** add-in dev server = `https://localhost:3000` (ตาม yo office) → Nuxt dev server ต้องตั้ง **port 5173** ใน `nuxt.config.ts` ห้ามใช้ default 3000
---
## 3. โครงสร้างไฟล์เป้าหมาย
```
/word-addin
/addin # Phase 0, 4, 5
manifest.xml
/src
/taskpane
taskpane.html
taskpane.tsx
auth-callback.html
/components
VersionHistoryPanel.tsx
ConfirmVersionDialog.tsx
/services
metadataService.ts # custom properties + custom XML part + content controls
versioningService.ts
fileService.ts
authService.ts
apiClient.ts
/models
versionRecord.ts
package.json
webpack.config.js
/backend # Phase 0, 2, 3
/WordVersioning.Api
Program.cs
/Controllers
DocumentsController.cs # FR-5–7 + generate endpoint + field sync
TemplatesController.cs # FR-9
/Models
Document.cs # + TemplateId
DocumentVersion.cs # + FieldsSnapshotJson
Template.cs
TemplateField.cs
DocumentFieldValue.cs
/Data
AppDbContext.cs
/Services
ObjectStorageService.cs
HashService.cs
DocxGenerationService.cs # FR-10: แทนที่ SDT + stamp custom properties
appsettings.json
WordVersioning.Api.csproj
/web # Phase 0, 6
nuxt.config.ts # devServer port 5173
tailwind.config.ts # darkMode: 'class'
/pages
index.vue # หน้าหลัก: เลือก template เพื่อสร้างเอกสาร
templates/index.vue # รายการ template (admin)
templates/new.vue # upload template + ตั้งค่า field defs
templates/[id]/edit.vue # แก้ไข template + field defs
documents/create/[templateId].vue # dynamic form สร้างเอกสาร (FR-10)
/composables
useAuth.ts # OIDC PKCE + browser redirect ปกติ
useApi.ts # fetch wrapper + Bearer token + refresh
useTheme.ts # light/dark toggle + persist + system default
/assets/css/tailwind.css
package.json
/keycloak # Phase 1
realm-export.json
docker-compose.yml # Phase 1
README.md # Phase 8
/specs /docs # มีอยู่แล้ว
```
**ลำดับ dependency:** `0 → 1 → 2 → 3` เป็นเส้นหลัก จากนั้นแยก 2 สายทำขนานกันได้: `3 → 4 → 5` (add-in) และ `3 → 6` (web); **Phase 7 (tests)** ทำขนานกับ 4–6 ได้; **Phase 8** ปิดท้าย
---
## Functional Requirements เพิ่มเติม (นอก spec เดิม)
### FR-9: Template management (backend + web)
- CRUD template: upload ไฟล์ .docx (มี content controls), ตั้ง name/description/active flag
- ตอน upload/replaces ไฟล์: backend **parse content controls (SDT tags) ออกมาเป็น field list อัตโนมัติ** ด้วย OpenXML
- Admin ตั้งค่าต่อ field: label, type (`text` / `number` / `date`), required, ลำดับ — เก็บเป็น `TemplateField`
- ลบ template = **soft-delete (deactivate) เท่านั้น** เพราะ document เก่ายังอ้างอยู่; เอกสารที่สร้างแล้วใช้ template ที่ deactivate ได้ต่อเนื่อง
- ไฟล์ template เก็บใน MinIO bucket `docx-templates` (private)
### FR-10: สร้างเอกสารจาก template ผ่านเว็บ
```
POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
```
- Validate: required fields ครบ, type ถูก (number/date parse ได้)
- Backend `DocxGenerationService`: โหลด template จาก MinIO → **แทนที่ข้อความใน SDT แต่ละ field (แก้ที่ run level เพื่อรักษา formatting)** → **stamp Custom Properties** (`SavedByAddIn` = ADDIN_SIGNATURE_GUID, `DocumentTrackingId` = GUID ใหม่) ลงในไฟล์ด้วย OpenXML
- สร้าง `Document` (ผูก `TemplateId`) + `DocumentFieldValue` ทุก field + **upload เป็น version 1** + snapshot fields ของ v1
- Response: `{ documentId, versionId, versionNumber, downloadUrl }` → ผู้ใช้ดาวน์โหลด .docx ไปเปิดใน Word → **FR-1 เดิมทำงานทันที** (add-in ตรวจพบ tracked document เห็น v1 โดยไม่ต้องกด Start Versioning)
### FR-11: Field sync จาก add-in → DB
- ตอน Save & Version: add-in อ่าน `context.document.contentControls` ทั้งหมด → ส่ง map `{ tag: text }` แนบกับ `POST .../versions`
- Backend เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update **latest value ต่อ field** (`DocumentFieldValue`) + เก็บ **snapshot ต่อ version** (`DocumentVersion.FieldsSnapshotJson` — ใช้ diff ใน history ได้)
- field ที่หาไม่เจอในเอกสาร (ผู้ใช้ลบ content control ไป) → **เก็บค่าเดิมไว้ + mark missing** ใน snapshot แต่**ไม่ fail ทั้ง flow**
- การ sync ผูกกับ JWT ของผู้กด Save & Version (`UpdatedByUserId` จาก claims ตาม FR-8 เดิม)
---
## Phase 0: Scaffold โครงสร้างโปรเจกต์
**เป้า:** ได้โครงที่ build/run ได้ทั้งสามส่วน ก่อนใส่ logic จริง
- [x] ตรวจ prerequisites: Node LTS + Yeoman + `generator-office`, .NET 8 SDK, Docker
- [x] `yo office` เลือก task pane, TypeScript, React → ย้าย output มาไว้ที่ `/addin`
- [x] `dotnet new webapi -n WordVersioning.Api` → `/backend/WordVersioning.Api`
- [x] เพิ่ม packages: `Npgsql.EntityFrameworkCore.PostgreSQL`, `Microsoft.EntityFrameworkCore.Design`, `AWSSDK.S3`, `Microsoft.AspNetCore.Authentication.JwtBearer`, `DocumentFormat.OpenXml`
- [x] Scaffold `/web`: Nuxt.js + TailwindCSS — ตั้ง **`devServer.port = 5173`** ใน `nuxt.config.ts` (ห้ามชน add-in ที่ 3000), `darkMode: 'class'` ใน tailwind config, โครง `useTheme.ts` (toggle + persist localStorage + default ตาม `prefers-color-scheme`)
- [x] สร้าง `.gitignore` (root) ครอบทั้ง node_modules/bin/obj/.nuxt/env แล้ว `git init`
- [x] ปรับ `manifest.xml` ฐานจาก yo office ให้ dev server ชี้ `https://localhost:3000`
หมายเหตุจากการทำจริง (2026-08-18):
- คำสั่ง scaffold: `npx yo office react addin word --ts` (generator-office ปัจจุบันใช้ projectType `react` แทน flag `--framework react`)
- .NET packages ที่รองรับ net8.0 ต้องตรึง `--version 8.0.11` (Npgsql.EFCore / EFCore.Design / JwtBearer) เพราะ latest (10.x) รองรับเฉพาะ net10.0; AWSSDK.S3 4.0.102.1 + DocumentFormat.OpenXml 3.5.1 ใช้ latest ได้
- Nuxt เวอร์ชันที่ได้ = 4.5.2 → โครง `app/` directory (`app/pages`, `app/composables`, `app/assets/css`) แทน root-level ตามแผนเดิม — ความหมายเดียวกัน
- ทดสอบ DoD ผ่านทั้งสามส่วน (webpack compiled successfully / Build succeeded 0 errors / หน้าแรก render ที่ 5173 พร้อม dark: variants แบบ class strategy)
**Deliverable:** โครง 3 โปรเจกต์ตาม section 3
**DoD:** `npm run build` ใน `/addin` ผ่าน + `dotnet build` ใน `/backend` ผ่าน + `npm run dev` ใน `/web` เปิดหน้าแรกได้ที่ `http://localhost:5173` พร้อม toggle light/dark ทำงาน
---
## Phase 1: Local dev infrastructure (docker-compose + Keycloak + MinIO)
**เป้า:** รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8)
- [ ] `docker-compose.yml` กำหนด services:
- `db` — `postgres:16` (volume สำหรับข้อมูล)
- `minio` — `minio/minio` (console เปิดด้วย) + init service สร้าง bucket **`docx-versions`** และ **`docx-templates`** แบบ **private** ทั้งคู่
- `keycloak` — `quay.io/keycloak/keycloak` start-dev + import realm อัตโนมัติ
- `api` — build จาก `/backend` (รอ Phase 2 เสร็จค่อย enable ก็ได้)
- [ ] `/keycloak/realm-export.json`:
- realm `word-versioning`
- client `word-addin-officejs` — public, Standard Flow, **PKCE S256 บังคับ**, ไม่มี secret, redirect URI `https://localhost:3000/auth-callback.html`
- client `word-versioning-web` — public, Standard Flow, PKCE S256, redirect URI `http://localhost:5173/*` (web ทุก route เพราะใช้ redirect ปกติ)
- client `word-addin-vsto` — public + PKCE (pre-register ตาม spec)
- สร้าง test user แรกไว้ใน realm export เลยก็ได้
- [ ] กำหนด env defaults (เช่น ใน `.env.example`): MinIO endpoint/keys, Keycloak admin password, DB password
**DoD:** `docker compose up` แล้ว (1) เข้า Keycloak admin console เห็น realm `word-versioning` + clients ครบ 3 ตัว (2) เข้า MinIO console เห็น bucket `docx-versions` + `docx-templates` ที่เป็น private
---
## Phase 2: Backend core — FR-5, FR-6, FR-7, FR-8 (ฝั่ง server)
**เป้า:** API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5)
### 2.1 Data layer
- [ ] `Models/Document.cs`, `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
- [ ] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
- [ ] EF migration แรก
### 2.2 Services
- [ ] `IObjectStorageService` + `ObjectStorageService` — `AmazonS3Client` ตั้ง `ServiceURL` = MinIO endpoint, **`ForcePathStyle = true`** (จำเป็นสำหรับ MinIO), methods: `PutAsync(bucket, key, stream, contentType)`, `GetStreamAsync(bucket, key)`, config (`Endpoint`, `AccessKey`, `SecretKey`, `UseSSL`) จาก `appsettings.json`/env; `BucketName` แยกเป็น `VersionsBucket` + `TemplatesBucket`
- [ ] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
### 2.3 Auth (FR-8 ฝั่ง backend)
- [ ] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
- [ ] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
- [ ] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
### 2.4 Endpoints (`DocumentsController`)
- [ ] `POST /api/documents/{documentId}/versions` (FR-5) — multipart: `file` + `clientVersionId` + **`fields` (JSON map tag→text, เตรียมทางไว้ให้ FR-11)**; auto-create `Document` ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key `{documentId}/v{versionNumber:D4}_{clientVersionId}.docx`; คำนวณ + เก็บ SHA256; response `{ versionId, versionNumber }`
- [ ] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
- [ ] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
- [ ] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
### 2.5 Cross-cutting
- [ ] CORS อนุญาต **`https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
- [ ] Logging ทุก upload/download/generate: documentId, versionId, userId
- [ ] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
**DoD:** `dotnet run` + Swagger: (1) เรียก endpoint ไหนก็ได้โดยไม่แนบ token → **401** (2) ขอ token จาก Keycloak ด้วย test user แล้ว upload ไฟล์ docx จริง → มี record ใน Postgres + object ใน MinIO + download กลับมาเปิดได้ (AC-3 ผ่าน)
---
## Phase 3: Backend template & generation — FR-9, FR-10 (ใหม่)
**เป้า:** ระบบ template ครบวงจร — upload พร้อม auto-detect fields + generate เอกสาร v1 พร้อม metadata
### 3.1 Entities + migrations
- [ ] `Template` — Id (Guid), Name, Description, ObjectKey (ใน bucket `docx-templates`), IsActive (soft-delete), CreatedAt/UpdatedAt
- [ ] `TemplateField` — Id, TemplateId, Key (= SDT tag), Label, Type (`text|number|date`), IsRequired, SortOrder; unique `(TemplateId, Key)`
- [ ] `DocumentFieldValue` — DocumentId, FieldKey, Value (string), UpdatedAt, UpdatedByUserId, SourceVersionId; unique `(DocumentId, FieldKey)` = latest value ต่อ field
- [ ] `Document` เพิ่ม `TemplateId?` + migration
### 3.2 TemplatesController (FR-9)
- [ ] `GET /api/templates` — list (filter active/all)
- [ ] `POST /api/templates` — multipart: ไฟล์ .docx + name/description → upload ลง MinIO + **parse SDT tags คืนเป็น field list ที่ detect ได้** (response ให้ admin ไปตั้ง label/type/required ต่อ)
- [ ] `PUT /api/templates/{id}` — แก้ metadata + บันทึก field definitions
- [ ] `PUT /api/templates/{id}/file` — แทนที่ไฟล์ (re-parse fields, เตือนถ้า tag หายไป)
- [ ] `DELETE /api/templates/{id}` = deactivate (soft-delete)
### 3.3 DocxGenerationService (FR-10)
- [ ] โหลด template จาก MinIO → เปิดด้วย `DocumentFormat.OpenXml`
- [ ] แทนที่ข้อความใน SDT แต่ละ field **ที่ run level** (รักษา run properties/formatting ของ template — ไม่ทับทั้ง paragraph)
- [ ] Stamp Custom Properties ผ่าน `CustomFilePropertiesPart`: `SavedByAddIn` = `ADDIN_SIGNATURE_GUID` (ค่าคงที่เดียวกับที่ add-in ใช้ตรวจ), `DocumentTrackingId` = Guid ใหม่
- [ ] `POST /api/documents/generate { templateId, values }`:
1. Validate required + type
2. Generate ไฟล์จาก template
3. คำนวณ SHA256 + upload เป็น **version 1** (`ObjectStorageService`)
4. สร้าง `Document` (TemplateId, FileName จาก template name + timestamp) + `DocumentFieldValue` ทุก field + `DocumentVersion.FieldsSnapshotJson` ของ v1
5. Response `{ documentId, versionId, versionNumber, downloadUrl }`
### 3.4 Field sync บน upload endpoint (FR-11 ฝั่ง backend)
- [ ] ขยาย `POST /api/documents/{documentId}/versions`: รับ `fields` (map tag→text) → เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update `DocumentFieldValue` (latest value) + เขียน `DocumentVersion.FieldsSnapshotJson` ของ version นั้น
- [ ] field ที่ส่งมาขาด (ผู้ใช้ลบ content control) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request**; field ที่ส่งมาเกินนิยาม → ignore
**DoD:** upload template จริง (เช่น สัญญาจ้างงานที่มี content controls) → detect fields ครบ → เรียก generate ผ่าน Swagger → ได้ docx ที่ (1) เปิดใน Word แล้วค่าถูกทุก field formatting ไม่พัง (2) Word บอก custom properties มี `SavedByAddIn`/`DocumentTrackingId` (ตรวจใน Advanced Properties หรือเปิดไฟล์ด้วย unzip) (3) DB มี Document + v1 + field values (AC-11, 12 ส่วน backend ผ่าน) และ (4) เรียก upload version ถัดไปพร้อม `fields` ที่เปลี่ยนค่า → latest values + snapshot อัปเดตถูก / ขาด field → ค่าเดิมคงอยู่ไม่ error (AC-13, 14 ส่วน backend ผ่าน)
---
## Phase 4: Add-in services layer — FR-8 (ฝั่ง client), FR-11 (extraction), โครง FR-1/2
**เป้า:** logic ทั้งหมดที่ UI จะเรียกใช้ + unit test — ตรง spec ส่วน 11 (frontend ข้อ 2, 3, 5, 6)
### 4.1 Auth (`authService.ts`, `auth-callback.html`)
- [ ] สร้าง `code_verifier` (random 43–128 chars) + `code_challenge` = base64url(SHA256(verifier))
- [ ] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html`) + response_type=code + scope=openid + state + code_challenge (S256)
- [ ] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
- [ ] `auth-callback.html` — parse `code`/`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
- [ ] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
- [ ] เก็บ `access_token`/`refresh_token` **ใน JS variable ของ task pane session**; expose `isTokenValid()`, `getAccessToken()`
- [ ] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
- [ ] ป้องกัน CSRF ด้วยการเทียบ `state`
### 4.2 Metadata (`metadataService.ts`)
- [ ] อ่าน Custom Properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
- [ ] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
- [ ] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
- [ ] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
- [ ] Constants: `ADDIN_SIGNATURE_GUID`
- [ ] **(FR-11) `readContentControlFields()`** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }`; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
### 4.3 File + API (`fileService.ts`, `apiClient.ts`, `versioningService.ts`)
- [ ] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
- [ ] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
- [ ] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)`, `getVersions(documentId)`, `getDownloadUrl(...)`/download — **fields = map จาก `readContentControlFields()`**
- [ ] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
### 4.4 Manifest
- [ ] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()`) + `DialogAPI`
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run`, `Office.context.ui`, `fetch`, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
---
## Phase 5: Task pane UI — FR-1, FR-2, FR-3, FR-4, FR-11 (ส่วนส่งค่า)
**เป้า:** UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4)
- [ ] `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`
- ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**)
- ไม่ตรง → ปุ่ม **"Start Versioning This Document"**
- [ ] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn`, `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
- [ ] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
- [ ] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
1. เปิด confirm dialog
2. Stamp properties: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`
3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
4. `context.document.save(Word.SaveBehavior.save)`
5. `fileService` ดึงไฟล์ปัจจุบัน + **`readContentControlFields()` ดึงค่า fields ปัจจุบัน (FR-11)**
6. `POST /api/documents/{documentId}/versions` — แนบ `fields` ด้วย
7. แสดงผล + refresh history
- ทุก step มี loading state + error message + **ห้ามปล่อย unhandled rejection**
- [ ] `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 ใคร)
- [ ] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
- [ ] Fluent UI components ให้หน้าตาเข้ากับ Office
**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)
---
## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)*
**เป้า:** เว็บ Nuxt + Tailwind สำหรับจัดการ template และสร้างเอกสาร ทุกหน้ารองรับ light/dark theme
### 6.1 พื้นฐาน
- [ ] `useAuth.ts` — OIDC PKCE + **browser redirect ปกติ** (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
- [ ] `useApi.ts` — fetch wrapper + Bearer + auto refresh ตอน 401
- [ ] `useTheme.ts` — toggle light/dark (สลับ class บน `<html>`), persist ใน localStorage, default ตาม `prefers-color-scheme`
- [ ] Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)
### 6.2 จัดการ template (FR-9 UI)
- [ ] `templates/index.vue` — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
- [ ] `templates/new.vue` + `templates/[id]/edit.vue` — upload/แทนที่ไฟล์ .docx → โชว์ **field list ที่ระบบ detect อัตโนมัติ** → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก
### 6.3 สร้างเอกสาร (FR-10 UI)
- [ ] `index.vue` — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
- [ ] `documents/create/[templateId].vue` — **dynamic form สร้างจาก field defs** (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
- [ ] กดสร้าง → แสดงผล: documentId, version 1 + **ปุ่มดาวน์โหลด .docx** + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
---
## Phase 7: Tests
**เป้า:** ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง spec ส่วน 11 (frontend ข้อ 7, backend ข้อ 7) + ครอบ features ใหม่
- [ ] **Frontend unit (Jest, ทำพร้อม Phase 4–5):**
- authService: PKCE pair ถูก format, state validation, token exchange, refresh flow
- metadataService: stamp ครบ 4 properties, XML delete-then-add, XML fail แล้วไม่ throw, **readContentControlFields แปลง map ถูก (tag ซ้ำ/ว่าง/ไม่มี control)**
- apiClient: แนบ Bearer, retry หลัง refresh, ยอมแพ้หลัง retry ครั้งที่ 2
- fileService: ประกอบ slice หลากขนาดเป็น Blob, เรียก closeAsync
- [ ] **Backend integration (xUnit + Testcontainers):** รัน PostgreSQL + MinIO + Keycloak จริง
- ขอ token ผ่าน Keycloak admin API สร้าง test user
- upload → มี record + object; upload ซ้ำ document → versionNumber เพิ่ม; download → bytes ตรง; ไม่มี token → 401; ไฟล์ไม่ใช่ docx → 400; `CreatedByUserId` ตรงกับ `sub` ใน JWT
- **Template:** upload docx มี SDT → detect tags ครบ; บันทึก field defs; deactivate แล้ว generate ไม่ได้แต่ document เก่ายังโหลดได้
- **Generate:** แทนที่ค่าครบทุก field + เปิดผลลัพธ์ด้วย OpenXML ตรวจ SDT text ตรง + **custom properties มี `SavedByAddIn`/`DocumentTrackingId`** + ได้ v1 + field values ใน DB; required ขาด → 400; type ผิด → 400
- **Field sync:** upload version พร้อม `fields` → latest values อัปเดต + snapshot เก็บต่อ version; **ส่ง map ขาด field → ค่าเดิมคงอยู่ + mark missing + request สำเร็จ (AC-14)**
- [ ] **Web (Vitest):** validation ของ dynamic form (required/type) + useTheme logic
**DoD:** `npm test` (addin), `npm test` (web) และ `dotnet test` เขียวทั้งคู่
---
## Phase 8: README + ตรวจ acceptance criteria
- [ ] `README.md`: วิธีรัน local dev (docker compose, dotnet run, npm start, **npm run dev ของ /web**), วิธี sideload add-in บน Desktop (Win/Mac) + Word Online, ค่า connection config, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก, **วิธีสร้าง template แรก (ตัวอย่างไฟล์ docx ที่มี content controls) และสร้างเอกสารจากเว็บ** — ตรง spec ส่วน 11 (backend ข้อ 9)
- [ ] ไล่ checklist acceptance criteria ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ
---
## 4. ตาราง mapping: Phase ↔ FR ↔ Acceptance Criteria
| Phase | FR ที่ implement | Acceptance criteria ที่ตรวจได้ |
|---|---|---|
| 0 | — | — (โครง) |
| 1 | FR-8 (Keycloak setup รวม web client) | — (infra) |
| 2 | FR-5, FR-6, FR-7, FR-8 (backend) | AC-3 |
| 3 | FR-9, FR-10 (backend) | AC-11, AC-12 (ส่วน API) |
| 4 | FR-8 (client auth), FR-11 (extraction), โครง FR-1/2 | AC-2 (ส่วน dialog) |
| 5 | FR-1, FR-2, FR-3, FR-4, FR-11 (ส่วนส่งค่า) | AC-1, 2, 4, 5, 6, 7, 8, 9, 10, 13 |
| 6 | FR-9 (UI), FR-10 (UI) | AC-11, AC-12 (เต็มรูปแบบ) |
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
| 8 | — | ทบทวน AC-1 ถึง AC-14 ทั้งหมด |
**AC เพิ่มเติม (นอก spec เดิม):**
- **AC-11:** upload template .docx ที่มี content controls → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้
- **AC-12:** กรอก form บนเว็บ → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 1 ทันที **โดยไม่ต้องกด Start Versioning**
- **AC-13:** แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
- **AC-14:** ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
---
## 5. ความเสี่ยงและ mitigation
| ความเสี่ยง | ผลกระทบ | Mitigation |
|---|---|---|
| Custom XML Part fail แบบ silent บน Word for Mac | history ในไฟล์หาย | ออกแบบไว้แล้ว: XML part เป็น secondary, backend คือ source of truth; metadataService catch ไม่ throw (AC-10) |
| ผู้ใช้เผลอ save ด้วย Ctrl+S แทนปุ่ม | ไม่มี version ใหม่ | ห้ามดักไม่ได้ตามข้อจำกัดแพลตฟอร์ม → FR-3 banner เตือนเมื่อ `document.saved === false` นานเกิน threshold |
| Access token หมดอายุกลางใช้งาน | request ล้ม 401 | apiClient/useApi auto-refresh + retry; refresh token ตาย → กลับหน้า sign-in เนียนๆ (AC-9) |
| ไฟล์ .docx ขนาดใหญ่ | upload ช้า/ล้ม | limit 100MB, stream upload ลง MinIO, SHA256 คำนวณระหว่าง stream |
| `getFileAsync` กิน memory/เปิดค้าง | resource leak ใน Word | เรียก `closeAsync` เสมอใน finally |
| Keycloak login โดน iframe blocker | login ไม่ได้เลย | ใช้ Dialog API ตั้งแต่ดีไซน์ (FR-8); `/web` เป็น top-level ไม่กระทบ |
| versionNumber race (สอง client upload พร้อมกัน) | version ซ้ำ → ผิด | unique index `(DocumentId, VersionNumber)` + retry ตอนชน constraint |
| **OpenXML replace ทำ formatting พัง** | เอกสารที่ generate หน้าตาผิดจาก template | แก้ที่ **run level ใน SDT** (ไม่ทับ paragraph) + integration test ด้วย template จริงหลายรูปแบบ |
| **ผู้ใช้ลบ/แก้ content control ใน Word** | field sync ไม่ครบ/ไม่ตรง | FR-11 policy: field หาย → เก็บค่าเดิม + mark missing, ไม่ fail (AC-14); tag ซ้ำ → เอาตัวแรก |
| **Template ถูกใช้กับ document เก่าจำนวนมาก** | ลบ template แล้วอ้างอิงพัง | soft-delete (IsActive) เท่านั้น; Document เก่ายังถือ TemplateId เดิมได้ |
| **Keycloak 3 clients + CORS 2 origins** | auth พังเฉพาะบาง client | realm-export ครบทุก client ตั้งแต่ Phase 1; backend CORS ใส่ทั้ง add-in และ web |
| **Port ชนกัน (Nuxt default 3000 = add-in)** | dev รันชนกันสับสน | บังคับ Nuxt ที่ **5173** ใน `nuxt.config.ts` ตั้งแต่ Phase 0 |
---
## 6. คำสั่งที่ใช้ประจำ (เมื่อ implement จริง)
```bash
# infra
docker compose up -d
# backend
cd backend/WordVersioning.Api
dotnet run # รัน + swagger ที่ http://localhost:5xxx
dotnet ef migrations add <Name>
dotnet test
# add-in (https://localhost:3000)
cd addin
npm install
npm run build # webpack build
npm start # dev server — sideload ผ่าน manifest นี้
npm test # jest
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้
# web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in)
cd web
npm install
npm run dev
npm test # vitest
```