2026-08-18 12:52:40 +07:00
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
2026-09-10 10:51:13 +07:00
> สถานะ: **Phase 0– 12** (Phase 0– 11 เสร็จครบ; Phase 12 เหลือ manual บน Word จริง — backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
2026-08-18 12:52:40 +07:00
> วันที่แผน: 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
2026-08-22 09:17:15 +07:00
- **FR-9 ถึง FR-14 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ปัจจุบันเป็นฉบับ v6 (ครอบ FR-1– 14 + AC-1– 17: v2 เพิ่ม template/portal/field sync, v3 เพิ่มข้อจำกัดแพลตฟอร์มจากการทดสอบจริง, v4 เพิ่มรายการเอกสารต่อ template + detail + re-generate, v5 เพิ่ม commit message ต่อ version, v6 เพิ่ม editor ต่อ field) ให้ถือ spec เป็น source of truth ของ requirements
2026-08-18 12:52:40 +07:00
---
## 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
2026-08-19 23:06:57 +07:00
eslint.config.mjs # office-addin-lint ใช้ไฟล์นี้แทน config ของมัน (ปิด rule ที่ crash)
jest.config.js
2026-08-18 12:52:40 +07:00
/src
/taskpane
taskpane.html
2026-08-19 23:06:57 +07:00
index.tsx # Office.onReady → FluentProvider + App
auth-callback.html/.ts # redirect target ของ Keycloak (รันใน Office Dialog)
2026-08-18 12:52:40 +07:00
/components
2026-08-19 23:06:57 +07:00
App.tsx # state machine: checking → signed-out → untracked → tracking
2026-08-18 12:52:40 +07:00
VersionHistoryPanel.tsx
ConfirmVersionDialog.tsx
/services
2026-08-19 23:06:57 +07:00
authService.ts # PKCE + Dialog API + token in-memory + silent refresh
2026-08-18 12:52:40 +07:00
metadataService.ts # custom properties + custom XML part + content controls
2026-08-19 23:06:57 +07:00
fileService.ts # getFileAsync → slices → Blob (normalize slice.data ทุกรูปแบบ)
apiClient.ts # fetch + Bearer + retry-on-401 ครั้งเดียว
versioningService.ts # uploadVersion / getVersions / download + download-link
versionFlow.ts # orchestration ของ Start Versioning / Save & Version (7 ขั้น)
errors.ts
2026-08-18 12:52:40 +07:00
/models
versionRecord.ts
2026-08-19 23:06:57 +07:00
/test # wordMock / httpMock / setup (jsdom polyfills)
2026-08-18 12:52:40 +07:00
package.json
2026-08-19 23:06:57 +07:00
webpack.config.js # DefinePlugin: KEYCLOAK_AUTHORITY / API_BASE_URL จาก env
/scripts
dev-token.py # เดิน PKCE จริงได้ token ทดสอบ backend (admin-cli ไม่มี aud)
2026-08-18 12:52:40 +07:00
/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
2026-08-20 13:51:42 +07:00
/WordVersioning.Api.IntegrationTests # Phase 7 — xUnit + Testcontainers (42 tests)
IntegrationTestFixture.cs # ยก PostgreSQL+MinIO+Keycloak จริง + WebApplicationFactory + migrations + buckets
TestDocx.cs # สร้าง/อ่าน .docx ของ test ใน memory (OpenXML) — ไม่มี binary ใน repo
TestFlow.cs # setup ร่วม: template + field defs + generate
TestDtos.cs # response DTO ฝั่ง test + multipart helpers
Fixtures/realm-word-versioning-test.json # realm ของ test (เปิด password grant เฉพาะ word-versioning-web)
AuthTests.cs / UploadVersionTests.cs / TemplateTests.cs / GenerateTests.cs / FieldSyncTests.cs
WordVersioning.slnx # ครอบทั้ง API + tests → `cd backend && dotnet test`
2026-08-18 12:52:40 +07:00
/web # Phase 0, 6
nuxt.config.ts # devServer port 5173
tailwind.config.ts # darkMode: 'class'
/pages
index.vue # หน้าหลัก: เลือก template เพื่อสร้างเอกสาร
2026-08-20 11:15:04 +07:00
auth/callback.vue # ปลายทาง OIDC redirect (แลก token แล้วพากลับ)
2026-08-18 12:52:40 +07:00
templates/index.vue # รายการ template (admin)
templates/new.vue # upload template + ตั้งค่า field defs
templates/[id]/edit.vue # แก้ไข template + field defs
2026-08-20 11:15:04 +07:00
templates/[id]/documents.vue # รายการเอกสารของ template (6.4/FR-12)
documents/create/[templateId].vue # dynamic form สร้างเอกสาร (FR-10) + prefill re-generate
documents/[documentId].vue # detail เอกสาร: fields + version history + re-generate (6.4/FR-12)
2026-08-18 12:52:40 +07:00
/composables
useAuth.ts # OIDC PKCE + browser redirect ปกติ
useApi.ts # fetch wrapper + Bearer token + refresh
useTheme.ts # light/dark toggle + persist + system default
2026-08-20 13:51:42 +07:00
/utils # oidc / fieldValidation / snapshot / format — pure functions (unit test ได้)
/test # Phase 7 — Vitest (fieldValidation/useTheme/snapshot)
setup.ts # stub Nuxt auto-imports (useState/computed) เบาๆ แทน @nuxt/test -utils
vitest.config.ts
2026-08-18 12:52:40 +07:00
/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)
2026-08-19 10:54:55 +07:00
- [x] `docker-compose.yml` กำหนด services:
2026-08-18 12:52:40 +07:00
- `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 ก็ได้)
2026-08-19 10:54:55 +07:00
- [x] `/keycloak/realm-export.json` :
2026-08-18 12:52:40 +07:00
- 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 เลยก็ได้
2026-08-19 10:54:55 +07:00
- [x] กำหนด env defaults (เช่น ใน `.env.example` ): MinIO endpoint/keys, Keycloak admin password, DB password
หมายเหตุจากการทำจริง (2026-08-19):
- Keycloak = `26.2.5` (tag `26.2` ); admin bootstrap ใช้ env `KC_BOOTSTRAP_ADMIN_USERNAME/PASSWORD` (ชุดใหม่แทน `KEYCLOAK_ADMIN` ที่ deprecated)
- Realm import strategy ของ Keycloak = `IGNORE_EXISTING` และ compose **ไม่ mount volume เก็บข้อมูล realm โดยตั้งใจ** → หลัง `docker compose down` realm จะถูก import ใหม่จาก `realm-export.json` เสมอ (ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev — แก้ realm ให้แก้ที่ไฟล์นี้)
- ทุก client ตั้ง `webOrigins` ด้วย (add-in: `https://localhost:3000` , web: `http://localhost:5173` ) — จำเป็นเพื่อให้ browser เรียก token endpoint ผ่าน CORS จาก task pane / เว็บได้ (ไม่ใช่แค่ redirectUris)
- ปิด `directAccessGrantsEnabled` ทุก client ตาม spec (บังคับ PKCE); ตอน dev อยากได้ token สำหรับทดสอบใช้ client `admin-cli` ของ realm (ROPC) หรือเดิน auth code flow จริง
- redirect URI ของ `word-addin-vsto` ใช้ loopback `http://localhost/*` + `http://127.0.0.1/*` ตาม RFC 8252 (spec-vsto.md ยังไม่มี — pre-register ไว้ก่อน)
- `api` service ใส่ไว้ใน compose แบบ `profiles: ["api"]` — ไม่ start ตอน `docker compose up` ปกติ จนกว่าจะมี Dockerfile ใน Phase 2 (`docker compose --profile api up -d --build` )
- ตรวจ DoD ผ่านหมด: realm + 3 clients (public/standardFlow/PKCE S256/redirect ถูกทุกตัว ผ่าน admin REST), `testuser/Test1234!` ขอ token ได้, bucket 2 ตัวเป็น private (`mc anonymous get` = private, anonymous GET = 403)
2026-08-18 12:52:40 +07:00
**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
2026-08-19 10:54:55 +07:00
- [x] `Models/Document.cs` , `Models/DocumentVersion.cs` ตาม spec ส่วน 7 + เพิ่ม `TemplateId` (Document) และ `FieldsSnapshotJson` (DocumentVersion) สำหรับ FR-10/11
- [x] `Data/AppDbContext.cs` (Npgsql) + unique index `(DocumentId, VersionNumber)`
- [x] EF migration แรก
2026-08-18 12:52:40 +07:00
### 2.2 Services
2026-08-19 10:54:55 +07:00
- [x] `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`
- [x] `HashService` — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
2026-08-18 12:52:40 +07:00
### 2.3 Auth (FR-8 ฝั่ง backend)
2026-08-19 10:54:55 +07:00
- [x] `AddAuthentication(JwtBearer)` config `Authority` = Keycloak realm URL, `Audience` ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), `RequireHttpsMetadata = false` (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
- [x] `[Authorize]` บน `DocumentsController` และ `TemplatesController` ทั้ง controller
- [x] Helper ดึง userId/username จาก claims `sub` / `preferred_username` — **ห้ามรับค่านี้จาก client**
2026-08-18 12:52:40 +07:00
### 2.4 Endpoints (`DocumentsController`)
2026-08-19 10:54:55 +07:00
- [x] `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 }`
- [x] `GET /api/documents/{documentId}/versions` (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
- [x] `GET /api/documents/{documentId}/versions/{versionId}/download` (FR-7) — **proxy stream** จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
- [x] Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size **100MB** (`RequestSizeLimit` / Kestrel config)
2026-08-18 12:52:40 +07:00
### 2.5 Cross-cutting
2026-08-19 10:54:55 +07:00
- [x] CORS อนุญาต ** `https://localhost:3000` (add-in) และ `http://localhost:5173` (web)**
- [x] Logging ทุก upload/download/generate: documentId, versionId, userId
- [x] `appsettings.json` + env overrides ครบทุก config (DB, MinIO, Keycloak)
หมายเหตุจากการทำจริง (2026-08-19):
- **Keycloak public client ไม่ใส่ claim `aud` ใน access token อัตโนมัติ** (ทดสอบจริงด้วย PKCE flow) → เพิ่ม `oidc-audience-mapper` ("audience-self") ให้ทุก client ใน `realm-export.json` เพื่อให้ token มี `aud` = clientId ตัวเอง แล้ว backend จึง validate `ValidAudiences = [word-addin-officejs, word-versioning-web]` ได้จริง (realm ต้อง re-import: `docker compose rm -sf keycloak && docker compose up -d` )
- ตรวจ token ได้โดยไม่ต้องเปิด browser: PKCE auth-code flow ทั้งเส้นด้วย curl (GET login form → POST credentials ตาม cookie → ตาม 302 เอา `code` → exchange กับ verifier) — ใช้ซ้ำได้ตอน dev/test
- `MapInboundClaims = false` เพื่ออ่าน claim ชื่อดิบ `sub` / `preferred_username` (ไม่โดนแปลงเป็น `http://schemas.xmlsoap.org/...` )
- download ใช้ stream wrapper ที่ครอบ `GetObjectResponse` (dispose stream = dispose response) — controller คืน `File(stream, ...)` ได้เลยไม่ต้องโหลดทั้งไฟล์ใน memory ไม่ต้องจัดการ lifetime ใน controller
- upload คำนวณ SHA256 จาก stream pass แรกแล้ว `Seek(0)` ส่ง MinIO pass สอง (IFormFile ของ Kestrel seek ได้เพราะ spool ลง disk แล้ว); ถ้า MinIO ล้มเหลวหลัง insert row จะถอน row กลับ (ไม่ทิ้ง record ชี้ไฟล์ที่ไม่มีจริง)
- content-type ยอมรับ docx mime ตรงๆ และ `application/octet-stream` /`application/zip` (client อย่าง curl ไม่ set ชนิดให้) แต่ชื่อไฟล์ต้องลงท้าย `.docx` เสมอ
- เลข version จองผ่าน insert + unique index — ชน constraint (request พร้อมกัน) แล้ว detach แล้วคำนวณใหม่สูงสุด 5 ครั้ง
- API dev URL = `http://localhost:5075` (run แบบ `dotnet run --no-launch-profile` + `ASPNETCORE_URLS` เพื่อไม่เปิด browser)
2026-08-18 12:52:40 +07:00
**DoD:** `dotnet run` + Swagger: (1) เรียก endpoint ไหนก็ได้โดยไม่แนบ token → **401** (2) ขอ token จาก Keycloak ด้วย test user แล้ว upload ไฟล์ docx จริง → มี record ใน Postgres + object ใน MinIO + download กลับมาเปิดได้ (AC-3 ผ่าน)
2026-08-19 10:54:55 +07:00
> ✅ ตรวจผ่านจริง (2026-08-19): 401 ทั้ง 3 endpoints; token จาก PKCE flow จริง (client `word-versioning-web`); upload v1 (พร้อม `fields`) + v2 → 201 versionNumber 1, 2; Postgres มี Document + 2 DocumentVersions (ContentHash ตรง sha256 ต้นทาง, CreatedByUserId = `sub`); MinIO มี object 2 ไฟล์ตาม key format; download กลับมาตรง byte-for-byte + content-type ถูก; GET history เรียงใหม่→เก่าพร้อม fields ที่ parse แล้ว; .txt → 400; CORS preflight จาก `https://localhost:3000` → 204 + allow-origin ถูก
2026-08-18 12:52:40 +07:00
---
## Phase 3: Backend template & generation — FR-9, FR-10 (ใหม่)
**เป้า:** ระบบ template ครบวงจร — upload พร้อม auto-detect fields + generate เอกสาร v1 พร้อม metadata
### 3.1 Entities + migrations
2026-08-19 12:15:20 +07:00
- [x] `Template` — Id (Guid), Name, Description, ObjectKey (ใน bucket `docx-templates` ), IsActive (soft-delete), CreatedAt/UpdatedAt
- [x] `TemplateField` — Id, TemplateId, Key (= SDT tag), Label, Type (`text|number|date` ), IsRequired, SortOrder; unique `(TemplateId, Key)`
- [x] `DocumentFieldValue` — DocumentId, FieldKey, Value (string), UpdatedAt, UpdatedByUserId, SourceVersionId; unique `(DocumentId, FieldKey)` = latest value ต่อ field (ทำเป็น composite PK)
- [x] `Document` เพิ่ม `TemplateId?` + migration (`TemplatesAndFieldSync` )
2026-08-18 12:52:40 +07:00
### 3.2 TemplatesController (FR-9)
2026-08-19 12:15:20 +07:00
- [x] `GET /api/templates` — list (filter active/all) + `GET /api/templates/{id}` (สำหรับหน้า edit เพิ่มเติมจากแผน)
- [x] `POST /api/templates` — multipart: ไฟล์ .docx + name/description → upload ลง MinIO + **parse SDT tags คืนเป็น field list ที่ detect ได้** (สร้าง TemplateField defaults ทันที: label=tag, type=text — admin ไปตั้งต่อผ่าน PUT)
- [x] `PUT /api/templates/{id}` — แก้ metadata (name/description/isActive) + บันทึก field definitions (key ต้องเป็นชุดที่ detect ไว้ — เพิ่ม/ลบ key ต้องผ่าน replace ไฟล์)
- [x] `PUT /api/templates/{id}/file` — แทนที่ไฟล์ (re-parse fields, response บอก `addedTags` /`removedTags` )
- [x] `DELETE /api/templates/{id}` = deactivate (soft-delete)
2026-08-18 12:52:40 +07:00
### 3.3 DocxGenerationService (FR-10)
2026-08-19 12:15:20 +07:00
- [x] โหลด template จาก MinIO → เปิดด้วย `DocumentFormat.OpenXml`
- [x] แทนที่ข้อความใน SDT แต่ละ field **ที่ run level** (รักษา run properties/formatting ของ template — ไม่ทับทั้ง paragraph)
- [x] Stamp Custom Properties ผ่าน `CustomFilePropertiesPart` : `SavedByAddIn` = `ADDIN_SIGNATURE_GUID` (ค่าคงที่เดียวกับที่ add-in ใช้ตรวจ), `DocumentTrackingId` = Guid ใหม่
- [x] `POST /api/documents/generate { templateId, values }` :
2026-08-18 12:52:40 +07:00
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)
2026-08-19 12:15:20 +07:00
- [x] ขยาย `POST /api/documents/{documentId}/versions` : รับ `fields` (map tag→text) → เทียบกับ `TemplateField` ของ template ที่ document ผูกอยู่ → update `DocumentFieldValue` (latest value) + เขียน `DocumentVersion.FieldsSnapshotJson` ของ version นั้น
- [x] field ที่ส่งมาขาด (ผู้ใช้ลบ content control) → เก็บค่าเดิม + mark missing ใน snapshot **ไม่ fail request** ; field ที่ส่งมาเกินนิยาม → ignore
หมายเหตุจากการทำจริง (2026-08-19):
- **ค่า `ADDIN_SIGNATURE_GUID` = `9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D` ** — ปักหมุดไว้ที่ `Services/AddInMetadata.cs` และ spec ส่วน 8 แล้ว — Phase 4 (add-in `metadataService` ) ต้องใช้ค่านี้เป๊ะ
- OpenXML SDK 3.5.1: root ของ custom properties = `DocumentFormat.OpenXml.CustomProperties.Properties` + `CustomDocumentProperty` (property `Name` /`FormatId` /`PropertyId` + child `VTLPWSTR` ); block SDT = `SdtBlock` /`SdtProperties` (ไม่ใช่ `Sdt` /`SdtPr` ) — service ที่เขียนใช้ `Tag` + LocalName matching จึงไม่ผูกกับชื่อ class เหล่านี้ (กัน SDT ทั้ง block/run/cell)
- **ห้าม set `TemplateField.Id` เองตอน add เข้า `template.Fields` ของ tracked parent** — Guid ที่มีค่าทำให้ EF relationship-fixup ตีเป็น Modified (UPDATE แถวที่ไม่มี → `DbUpdateConcurrencyException` ) ทิ้ง Id ว่างไว้ให้ EF generate ตาม convention
- **AWSSDK.S3 4.x dispose input stream ให้หลัง `PutObjectAsync` ** — `ObjectStorageService.PutAsync` จับ `content.Length` ไว้ก่อนเรียก SDK แล้วใช้ค่านั้นทั้งตอน set ContentLength และ log
- snapshot ของเอกสารที่ผูก template เก็บแบบ structured `{ key: { value, missing } }` (camelCase) เพื่อรองรับ mark-missing; เอกสารไม่ผูก template ยังเก็บ map ดิบตาม Phase 2 — UI (Phase 5/6) ต้อง handle ทั้งสองรูป
- sync logic แชร์ผ่าน `FieldSyncService.StageFieldSyncAsync` (stage ลง tracker, caller เป็นคน SaveChanges) ใช้ทั้งตอน generate v1 และ upload version — UploadVersion ถอน staged field changes กลับด้วยตอน MinIO fail (ไม่ให้ค่าล้ำ version ที่ถูกถอน)
- generate endpoint upload MinIO **ก่อน** SaveChanges (ได้เลข v1 ล่วงหน้า ไม่ต้องจองเลข) — ตรงข้ามกับ UploadVersion ที่ insert ก่อนเพื่อจองเลข; template create/replace ก็ upload ก่อนเช่นกัน (ถ้า DB fail ทีหลังเหลือ orphan object ใน bucket — ยอมรับได้ เพราะทิศตรงข้าม (row ชี้ไฟล์ที่ไม่มี) คืออันที่อันตราย)
- ตรวจ DoD ผ่านจริง (2026-08-19): upload template (5 SDT: inline/run เดียว, multi-run คนละ formatting, block-level) → detect ครบ 5 + บันทึก defs ผ่าน PUT; validation ทำงาน (required ขาด / salary ไม่ใช่เลข / start_date ไม่ใช่วันที่ → 400 คนละข้อความ); generate → 201 + docx ค่าครบทุก field + multi-run ถูกรวมเป็น run เดียว (formatting ตัวแรก) + `docProps/custom.xml` มี `SavedByAddIn` /`DocumentTrackingId` ตรงกับ documentId + DB มี Document/v1/field values 5 แถว; upload v2 (แก้ 2 fields + ลบ position + ส่งเกิน 1 field) → 201, latest values อัปเดตเฉพาะ field ที่มี, position คงค่าเดิม + snapshot v2 `missing:true` , field เกินถูก ignore; replace-file → addedTags/removedTags ถูก; deactivate 204 → generate 400 แต่ history/download ของ document เก่ายัง 200
2026-08-18 12:52:40 +07:00
**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 ผ่าน)
2026-08-19 12:15:20 +07:00
> ✅ ตรวจผ่านจริง (2026-08-19): ครบทั้ง 4 ข้อตามรายละเอียดในหมายเหตุข้างบน (ตรวจ formatting/custom properties ด้วย OpenXML + unzip `docProps/custom.xml` ตามทางเลือกที่ DoD ยอมรับ — ส่วน "เปิดใน Word จริง" จะเกิดเต็มรูปแบบตอน AC-12 ใน Phase 6)
2026-08-18 12:52:40 +07:00
---
## 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`)
2026-08-19 21:35:07 +07:00
- [x] สร้าง `code_verifier` (random 43– 128 chars) + `code_challenge` = base64url(SHA256(verifier))
- [x] สร้าง authorization URL: Keycloak endpoint + client_id + redirect_uri (`auth-callback.html` ) + response_type=code + scope=openid + state + code_challenge (S256)
- [x] เปิด `Office.context.ui.displayDialogAsync(url, {height: 60, width: 30})` + `Dialog.addEventHandler(Office.EventType.DialogMessageReceived)` รับ message กลับ
- [x] `auth-callback.html` — parse `code` /`state` จาก URL, `Office.context.ui.messageParent(JSON.stringify({code, state}))` แล้ว `window.close()`
- [x] แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
- [x] เก็บ `access_token` /`refresh_token` **ใน JS variable ของ task pane session** ; expose `isTokenValid()` , `getAccessToken()`
- [x] Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
- [x] ป้องกัน CSRF ด้วยการเทียบ `state`
2026-08-18 12:52:40 +07:00
### 4.2 Metadata (`metadataService.ts`)
2026-08-19 21:35:07 +07:00
- [x] อ่าน Custom Properties: `SavedByAddIn` , `DocumentTrackingId` , `LastVersionId` , `LastVersionTimestamp`
- [x] เขียน/stamp properties (ใช้ property `add()` ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
- [x] XML part ฝั่งอ่าน: หา part ที่ namespace `urn:myaddin:versioning` → parse เป็น list
- [x] XML part ฝั่งเขียน: **delete ของเดิมทั้งหมดก่อนค่อย `add()` ใหม่** + **catch error แบบไม่ throw** (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
- [x] Constants: `ADDIN_SIGNATURE_GUID`
- [x] ** (FR-11) `readContentControlFields()` ** — `Word.run` → `context.document.contentControls` → `load("tag, text")` → คืน `{ [tag]: text }` ; เอกสารที่ไม่มี content controls → คืน `{}` ไม่ error
2026-08-18 12:52:40 +07:00
### 4.3 File + API (`fileService.ts`, `apiClient.ts`, `versioningService.ts`)
2026-08-19 21:35:07 +07:00
- [x] `fileService` — `getFileAsync(Office.FileType.Compressed)` → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → **เรียก `closeAsync` เสมอ** (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
- [x] `apiClient` — wrapper fetch ที่แนบ `Authorization: Bearer <token>` ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
- [x] `versioningService` — `uploadVersion(documentId, blob, clientVersionId, fields)` , `getVersions(documentId)` , `getDownloadUrl(...)` /download — **fields = map จาก `readContentControlFields()`**
- [x] `models/versionRecord.ts` — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)
2026-08-18 12:52:40 +07:00
### 4.4 Manifest
2026-08-19 21:35:07 +07:00
- [x] `manifest.xml` ประกาศ requirement sets ให้ครอบ `WordApi 1.5` (customProperties, customXmlParts, `document.save()` ) + `DialogAPI`
หมายเหตุจากการทำจริง (2026-08-19):
- **`customXmlParts` อยู่บน `context.document` ไม่ใช่ `context` ** (ตาม @types/office -js — WordApi 1.4) — ใช้ `context.document.customXmlParts.getByNamespace(...)` / `.add(...)`
- **Config inject ผ่าน webpack DefinePlugin** (`KEYCLOAK_AUTHORITY` / `KEYCLOAK_CLIENT_ID` / `API_BASE_URL` จาก env ตอน build, default dev = Keycloak 8080 / backend 5075) — อ่านที่ `src/config.ts` ; `redirectUri` derive จาก `window.location.origin + "/auth-callback.html"` ให้ตรง realm-export เสมอ
- `auth-callback.html` ต้องอยู่ **root ของ dist** ตรง redirect URI — เพิ่ม entry `authCallback` + HtmlWebpackPlugin แยกใน webpack.config.js (template `src/taskpane/auth-callback.html` + logic ใน `auth-callback.ts` , ไม่ใช่ React)
- **Jest ใช้ babel-jest** (คอมไพล์ด้วย babel.config.json เดิม) ไม่ใช่ ts-jest — ts-jest@29 peerOptional ไม่รับ @babel/core 8 ของ scaffold; ผลข้างเคียง: test ไม่ type-check ตอนรัน (type ตรวจที่ `npm run build` ผ่าน ts-loader ซึ่งไล่ทั้ง program รวม *.test.ts แล้ว)
- jsdom env ไม่มี `Response` /`fetch` /`crypto.subtle` และ `crypto` เป็น getter-only (assign ตรงๆ เงียบ) → `src/test/setup.ts` ต้อง `Object.defineProperty` แล้วดึง webcrypto จาก node; mock HTTP จึงเป็น duck-typing object ไม่ใช่ `Response` จริง (ดู `src/test/httpMock.ts` )
- test ที่รอ dialog ต้อง **poll จน `displayDialogAsync` ถูกเรียก** แทน setTimeout(0) — บนเครื่องโหลด `crypto.subtle.digest` อาจยังไม่จบภายใน macrotask เดียว (มิฉะนั้น message หลุดก่อน handler ลงทะเบียน)
- **tsconfig เพิ่ม `types` จำกัด** (office-js, office-runtime, react, react-dom, jest, node) — ไม่อย่างนั้น `@types/babel__core` ที่ jest/babel-jest ดึงเข้ามาชนกับ @babel/types v8 จน tsc พัง
- **eslint: สร้าง `eslint.config.mjs` ของโปรเจกต์** (office-addin-lint จะใช้แทน config ของมันถ้ามีไฟล์นี้ใน root) เพราะ rule `no-navigational-load` ของ plugin 4.0.10 (a) crash กับ `context.load(obj)` แบบ 1 อาร์กิวเมนต์ (b) false positive กับ Word properties เช่น `tag` — ปิด rule นี้ + ปิด `no-undef` /`no-redeclare` สำหรับ .ts/.tsx (tsc ตรวจแทนแม่นกว่า)
- manifest: element `Requirements` วางระหว่าง `</Hosts>` กับ `<DefaultSettings>` — `npm run validate` ผ่าน และ platform analysis ยังครบ (Word desktop/web/iPad ทุกรุ่นรองรับ WordApi 1.5 + DialogAPI 1.1)
- `readTrackedProperties` ใช้ `getItemOrNullObject(...).load("value")` แล้วอ่าน `isNullObject` หลัง sync (null-object proxy ต้องถูก load อย่างน้อย 1 property ค่า isNullObject จึงแม่น)
- Services ยังไม่ถูก import เข้า taskpane entry (เป็นงาน Phase 5) — ครั้งแรกที่จะรันจริงใน Word คือตอน wire UI
**DoD:** `npm run build` ผ่าน + unit tests ผ่าน (Jest: mock `Word.run` , `Office.context.ui` , `fetch` , Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, **readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)**
> ✅ ตรวจผ่านจริง (2026-08-19): `npm run build` compiled successfully (0 errors), `npm test` 40/40 ผ่านทั้ง 4 ชุด (authService 14, metadataService 14, apiClient 8, fileService 4 รวมกรณี slice พังกลางทาง→ยัง closeAsync, closeAsync พัง→ยังได้ blob), `npm run lint` ผ่าน, `npm run validate` (manifest) ผ่าน
2026-08-18 12:52:40 +07:00
**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)
2026-08-19 23:06:57 +07:00
- [x] `taskpane.tsx` state machine หลัก: `checking → signed-out → untracked → tracking`
- [x] **Sign-in gate (FR-1):** `Office.onReady()` → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property `SavedByAddIn` เทียบ `ADDIN_SIGNATURE_GUID`
2026-08-18 12:52:40 +07:00
- ตรง → อ่าน `DocumentTrackingId` → `getVersions()` → เข้าสถานะ `tracking` (**ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning**)
- ไม่ตรง → ปุ่ม ** "Start Versioning This Document"**
2026-08-19 23:06:57 +07:00
- [x] **Start Versioning:** stamp metadata รอบแรก (`SavedByAddIn` , `DocumentTrackingId` ใหม่) → upload version แรก → สลับเป็น `tracking`
- [x] `ConfirmVersionDialog.tsx` — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
- [x] **Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:**
2026-08-18 12:52:40 +07:00
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**
2026-08-19 23:06:57 +07:00
- [x] `VersionHistoryPanel.tsx` (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (`GET .../download` )
- [x] **Soft reminder (FR-3):** polling/debounce ทุก ~30 วินาทีเช็ค `context.document.saved` — `false` ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
- [x] จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
- [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 ทุกครั้ง
2026-08-18 12:52:40 +07:00
**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)
2026-08-20 13:51:42 +07:00
> 🟡→ อัปเดต (2026-08-20): 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 รอบและแก้หมดแล้ว (ดู 🩹 ด้านล่าง) · รายการ "ยังเหลือ" เดิมถูกแทนที่: AC-13/10 คุ้มแล้วด้วย integration tests ของ Phase 7 ส่วนที่เหลือ (ยืนยัน presigned link บน Mac อีกครั้ง, Word Online/Windows, AC-9 จริง) รวบอยู่ใน checklist งานมือท้าย **[`docs/acceptance-review.md`](../acceptance-review.md)**
2026-08-19 23:06:57 +07:00
2026-08-18 12:52:40 +07:00
---
## Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) *(ใหม่)*
**เป้า:** เว็บ Nuxt + Tailwind สำหรับจัดการ template และสร้างเอกสาร ทุกหน้ารองรับ light/dark theme
### 6.1 พื้นฐาน
2026-08-20 06:20:16 +07:00
- [x] `useAuth.ts` — OIDC PKCE + **browser redirect ปกติ** (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
- [x] `useApi.ts` — fetch wrapper + Bearer + auto refresh ตอน 401
- [x] `useTheme.ts` — toggle light/dark (สลับ class บน `<html>` ), persist ใน localStorage, default ตาม `prefers-color-scheme`
- [x] Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)
2026-08-18 12:52:40 +07:00
### 6.2 จัดการ template (FR-9 UI)
2026-08-20 06:20:16 +07:00
- [x] `templates/index.vue` — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
- [x] `templates/new.vue` + `templates/[id]/edit.vue` — upload/แทนที่ไฟล์ .docx → โชว์ **field list ที่ระบบ detect อัตโนมัติ** → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก
2026-08-18 12:52:40 +07:00
### 6.3 สร้างเอกสาร (FR-10 UI)
2026-08-20 06:20:16 +07:00
- [x] `index.vue` — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
- [x] `documents/create/[templateId].vue` — **dynamic form สร้างจาก field defs** (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
- [x] กดสร้าง → แสดงผล: documentId, version 1 + **ปุ่มดาวน์โหลด .docx** + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")
หมายเหตุจากการทำจริง (2026-08-20):
- โครงไฟล์ตาม Nuxt 4 (dir `app/` ): `app/composables` , `app/utils` , `app/components` , `app/plugins` auto-import — **import ข้ามโฟลเดอร์ต้องใช้ alias `~/utils/...`** (relative `./oidc` จาก composables ไม่ resolve — เจอตอน build แรก)
- **นโยบาย session ของเว็บ (กติกา #4 + แผนข้อ 5):** access/refresh/id token เก็บใน memory เท่านั้น; สิ่งที่ persist มีแค่ refresh token + id token (ไว้ restore หลัง reload และ logout แบบมี `id_token_hint` ) ใน sessionStorage key `wv-session` + PKCE pending (`wv-oidc-pending` ) ต้องข้าม navigation จึงจำเป็น — sessionStorage อายุสั้นตามที่แผนอนุญาต
- restore session ทำใน `app/plugins/auth.client.ts` (**async plugin** → รอผลก่อน render หน้าแรก กัน UI กระพริบไปหน้า login); สถานะ login ใน header ครอบ `<ClientOnly>` กัน hydration mismatch; ทุกหน้าครอบเนื้อหาด้วย `AuthGate` (ยังไม่ login → ปุ่มเข้าระบบ)
- `useApi` : Bearer ทุก request, 401 → force refresh → retry ครั้งเดียว; refresh พัง → เคลียร์ session แล้ว AuthGate สลับไปหน้า login เอง (พฤติกรรมเดียวกับ AC-9 ของ add-in) — และ `filenameFrom()` อ่านชื่อไฟล์จาก `Content-Disposition` รองรับ `filename*=UTF-8` ของ .NET (ชื่อไทย)
- ตรวจอัตโนมัติผ่านหมด (2026-08-20): `npm run build` exit 0 · SSR ทุก route (/, /templates, /templates/new, /templates/{id}/edit, /auth/callback ±query, /documents/create/{id}) ตอบ 200 พร้อมข้อความถูกต้อง · **เดิน PKCE จริงด้วยพารามิเตอร์เหมือน `useAuth` ทุกตัว** (redirect `/auth/callback` , scope=openid, S256) → Keycloak คืน code+state ตรง → แลก token ได้ `aud=word-versioning-web` + refresh/id token → ยิง `GET /api/templates` 200 → CORS preflight จาก 5173 ได้ 204 + allow-origin ถูก → refresh_token grant ผ่าน (path ที่ restoreSession ใช้) · **จำลอง request ทั้งลำดับที่หน้าเว็บส่ง** (multipart สร้าง template → PUT field defs → generate → download) → docx ค่าครบ + `SavedByAddIn` /`DocumentTrackingId` stamp ถูก
- 🩹 **รัน backend เจอ 500 "No password has been provided"** : `dotnet run --no-launch-profile` ตัด `ASPNETCORE_ENVIRONMENT=Development` (ปกติตั้งโดย launchSettings) ทิ้ง → ไปอ่าน appsettings.json ที่ DB password ว่าง — ต้องใส่ env นี้เองทุกครั้งที่ใช้ `--no-launch-profile`
- ดาวน์โหลดในเว็บใช้ blob + `a[download]` ได้ตามปกติ (browser จริง — ต่างจาก webview ของ Word ที่ต้องใช้ presigned link ตามกติกา #12 )
- ⚠️ **เหลือตรวจด้วยมือ (ทำแทนไม่ได้):** เปิด browser จริง → login ผ่าน Keycloak → สร้าง/แก้ template → กรอกฟอร์มสร้างเอกสาร → ดาวน์โหลด → เปิดใน Word แล้ว add-in เห็น v1 ทันที (AC-11/12 เต็มรูปแบบ) + theme toggle จำค่าหลัง reload — ใน dev DB มี template "สัญญาทดสอบเว็บ" (เกิดจากสคริปต์ตรวจ) ไว้ click-through ได้ทันที และลบผ่านหน้าเว็บได้เอง
2026-08-18 12:52:40 +07:00
2026-08-20 11:15:04 +07:00
### 6.4 รายการเอกสารต่อ template + Document detail + Re-generate — FR-12 *(เพิ่มทีหลัง, v4)*
- [x] Backend `GET /api/templates/{templateId}/documents` — รายการเอกสารของ template (ชื่อไฟล์, versionCount, version ล่าสุด + เวลา) เรียงกิจกรรมล่าสุดก่อน, 404 เมื่อ template ไม่มี, ใช้ได้แม้ deactivate
- [x] Backend `GET /api/documents/{documentId}` — detail: หัวเรื่อง + template + ค่าล่าสุดทุก field (merge `TemplateField` defs × `DocumentFieldValue` แบบ 3 query ใน memory — รองรับ orphan สองทาง: def ใหม่ไม่มีค่า / ค่าเก่าไม่มี def)
- [x] `templates/[id]/documents.vue` — ตารางเอกสารของ template + ลิงก์เข้า detail (จุดเข้า: ปุ่ม "เอกสาร" ในตาราง template + ลิงก์บน card หน้าหลัก)
- [x] `documents/[documentId].vue` — fields ปัจจุบัน + version history ขยายแถวเห็น snapshot ต่อ version (`normalizeSnapshot` รองรับ snapshot ทั้ง 2 รูปแบบ) + ดาวน์โหลดต่อ version
- [x] ปุ่ม re-generate บนหน้า detail → ฟอร์มสร้างเอกสาร prefilled (`?from={documentId}` ) → generate เป็น**เอกสารใหม่ v1** เอกสารต้นทางไม่ถูกแตะ (frontend composition ผ่าน endpoint เดิม — ไม่เพิ่ม backend)
หมายเหตุจากการทำจริง (2026-08-20):
- ออกแบบ backend ร่วมกับ Plan agent: query รายการใช้ correlated aggregate subquery (cast `(int?)` ใน `Max` จำเป็น — pattern เดียวกับ UploadVersion) + `LatestActivity` ต้อง compute ใน projection ก่อน `OrderBy` — index เดิมพอ ไม่ต้อง migrate
- กติกา merge ของ detail สำคัญกับข้อมูลจริง: ทดสอบกับเอกสารที่ template ถูก replace ไฟล์มาแล้ว เจอครบทั้ง 3 กรณี (def+ค่า / def ใหม่ไม่มีค่า → `""` + nulls / ค่าเก่าไม่มี def → label null ต่อท้าย)
- re-generate เลือก frontend composition (detail → prefill ฟอร์ม → generate เดิม) ตามที่ผู้ใช้เลือก "ตาม flow": policy deactivate ได้ฟรีจาก Generate (400 เดิม) + กรณี template เพิ่ม required field ทีหลัง ฟอร์ม prefilled ให้ผู้ใช้เติมค่าที่ขาดแทนที่จะตายเงียบ
- ตรวจอัตโนมัติผ่านหมด: `dotnet build` 0 warnings · curl ครบทั้ง list/detail/404× 2/เอกสารไม่ผูก template (`templateId: null` , fields ว่าง) · `npm run build` exit 0 + SSR ทุก route ใหม่ 200 · จำลอง re-generate ตามลำดับ request ที่หน้าเว็บส่ง → เอกสารใหม่ v1 ค่า prefill ตรง + **versionCount เอกสารต้นทางคงเดิม** · รายการเรียงล่าสุดก่อนถูก
- หน้า create เพิ่ม: banner บอกบริบท re-generate + เตือน/disable เมื่อ template deactivate; "สร้างอีกฉบับ" คืนค่า prefill (ไม่ใช่ฟอร์มว่าง) เมื่ออยู่ในบริบท re-generate
2026-08-18 12:52:40 +07:00
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
2026-08-20 13:51:42 +07:00
> 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ฝั่ง backend/generate คุ้มแล้วด้วย integration tests (custom properties + v1 ครบ) — **การยืนยันบน Word จริงอยู่ใน checklist งานมือท้าย [`docs/acceptance-review.md`](../acceptance-review.md)** (ตอนนั้นยังต้องรอ Phase 8 ตอนนี้ไล่ครบแล้ว)
2026-08-20 06:20:16 +07:00
2026-08-18 12:52:40 +07:00
---
## Phase 7: Tests
**เป้า:** ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง spec ส่วน 11 (frontend ข้อ 7, backend ข้อ 7) + ครอบ features ใหม่
2026-08-20 13:51:42 +07:00
- [x] **Frontend unit (Jest, ทำพร้อม Phase 4– 5):** — 47 tests ผ่าน
2026-08-18 12:52:40 +07:00
- 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
2026-08-20 13:51:42 +07:00
- fileService: ประกอบ slice หลากขนาดเป็น Blob (ครบทั้ง 4 รูปแบบของ `slice.data` ), เรียก closeAsync
- [x] **Backend integration (xUnit + Testcontainers):** — 42 tests, project ใหม่ `backend/WordVersioning.Api.IntegrationTests` (+ `backend/WordVersioning.slnx` ครอบทั้ง API + tests)
- รัน PostgreSQL + MinIO + Keycloak จริง (image เดียวกับ docker-compose) ผ่าน collection fixture เดียว — `WebApplicationFactory<Program>` (ต้องเพิ่ม `public partial class Program;` ท้าย Program.cs) ชี้ config ไปที่ container ทั้งหมด + รัน EF **migrations จริง** + สร้าง bucket ทั้งสอง
- token ของ test user: ใช้ `testuser` ที่มาพร้อม realm import + **password grant** (เปิดให้ client `word-versioning-web` เฉพาะใน realm ของ test) — backend ยัง validate ผ่าน JWKS/issuer/audience เต็มขั้นตอนเหมือน production; `sub` อ่านจาก JWT payload เพื่อเทียบ `CreatedByUserId`
- upload → มี record + object; upload ซ้ำ document → versionNumber เพิ่ม; download → bytes ตรง; ไม่มี token → 401; ไฟล์ไม่ใช่ docx → 400; `CreatedByUserId` ตรงกับ `sub` ใน JWT ✓ ครบใน `UploadVersionTests` + `AuthTests`
- **Template:** upload docx มี SDT → detect tags ครบ (run เดี่ยว/multi-run/block/tag ซ้ำ → dedup); บันทึก field defs (reject key นอกไฟล์ + type ผิด); deactivate แล้ว generate ไม่ได้แต่ document เก่ายังโหลดได้ ✓ `TemplateTests`
- **Generate:** แทนที่ค่าครบทุก field + เปิดผลลัพธ์ด้วย OpenXML ตรวจ SDT text ตรง + **multi-run รวมเป็น run เดียวรักษา formatting ตัวแรก** + custom properties มี `SavedByAddIn` /`DocumentTrackingId` + ได้ v1 + field values ใน DB; required ขาด → 400; type ผิด → 400; ค่าเกินนิยาม → ignore ✓ `GenerateTests` + รวม FR-12 (detail merge orphan สองทาง, รายการเอกสารต่อ template)
- **Field sync:** upload version พร้อม `fields` → latest values อัปเดต (เฉพาะ field ที่เปลี่ยน) + snapshot เก็บต่อ version; **ส่ง map ขาด field → ค่าเดิมคงอยู่ + mark missing + request สำเร็จ (AC-14)** ✓ `FieldSyncTests`
- ไฟล์ .docx ของ test สร้างใน memory ด้วย OpenXML (`TestDocx.cs` ) — ไม่ต้องเก็บ binary ใน repo
- [x] **Web (Vitest):** — 36 tests, ติดตั้ง `vitest` + `happy-dom` + `test/` (config + setup stub Nuxt auto-imports: useState/computed)
- validation ของ dynamic form (required/type ครบ edge: comma, เครื่องหมาย, วันที่ ISO) ✓ `fieldValidation.spec.ts`
- useTheme logic (toggle + class + persist + system default + state แชร์ตาม key) ✓ `useTheme.spec.ts`
- เพิ่มเติม: `snapshot.spec.ts` ครอบ `normalizeSnapshot` ทั้ง 2 รูปแบบ snapshot (ใช้จริงในหน้า detail)
หมายเหตุจากการทำจริง (2026-08-20):
- **`WithResourceMapping(FileInfo, string)` ของ Testcontainers 4.14 รับ target เป็น "directory" ไม่ใช่ file path** — ชี้ path ไฟล์ตรงๆ แล้วไฟล์จะไปอยู่ใน subdirectory ชื่อแปลกๆ → Keycloak import ไม่เจอ realm → discovery endpoint 404 ตลอด (ต้องชี้ `/opt/keycloak/data/import` แล้วให้ copy ชื่อเดิม)
- **Keycloak 26 บังคับ HTTPS บน token endpoint ของ master realm แม้ใน container http** → ใช้ master admin API สร้าง user ไม่ได้ (แผนเดิม) — เปลี่ยนเป็นใส่ user มากับ realm import เลย + password grant; realm ของ test ตั้ง `sslRequired: none` อยู่แล้วจึงใช้ http ใน container ได้
- Keycloak container "พร้อม" ช้ากว่า port เปิด (docker-proxy รับ connection แล้วตัดสาย → "response ended prematurely") → fixture poll discovery endpoint จนได้ 200 จริง + แนบ container logs ตอน timeout (แก้ปัญหาแบบมีหลักฐานไม่ใช่เดา)
- Testcontainers 4.x: constructor แบบ parameterless ของ module builder เลิกใช้ — ส่ง image ตอน construct (`new PostgreSqlBuilder("postgres:16")` ); `GetLogsAsync` คืน tuple `(Stdout, Stderr)` ; AWSSDK.S3 v4 ไม่มี `DoesS3BucketExist` (ใช้ PutBucket + จับ BucketAlreadyOwnedByYou)
- **`import.meta.client` ของ Nuxt ไม่ถูก replace ใน Vitest** (vite-node ไม่ apply `define` กับ `import.meta.*` ) → `useTheme` เปลี่ยน guard เป็น `typeof window !== 'undefined'` — semantics เทียบเท่าใน SSR/client และ test ได้โดยไม่พึ่ง transform เฉพาะของ Nuxt
- รัน backend tests: `cd backend && dotnet test` (slnx ครอบทั้งสอง project) — ต้องมี Docker รันอยู่ (Testcontainers สร้าง/ทำลาย container เองทุก run — ใช้เวลา ~1 นาทีต้นทุนคงที่สำหรับการยก infra จริง)
2026-08-18 12:52:40 +07:00
**DoD:** `npm test` (addin), `npm test` (web) และ `dotnet test` เขียวทั้งคู่
2026-08-20 13:51:42 +07:00
> ✅ ตรวจผ่านจริง (2026-08-20): `cd backend && dotnet test` **42/42** (Testcontainers ยก infra จริงครบทุกครั้ง) · addin `npm test` **47/47** · web `npm test` **36/36** — รวม **125 ตัวเขียวทั้งชุด**
2026-08-18 12:52:40 +07:00
---
## Phase 8: README + ตรวจ acceptance criteria
2026-08-20 11:15:04 +07:00
- [x] `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) — ✅ เติมครบแล้ว (2026-08-20) รวม web portal walkthrough ทุกหน้า + re-generate + troubleshooting
2026-08-20 13:51:42 +07:00
- [x] ไล่ checklist acceptance criteria ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ — ✅ (2026-08-20) ผลครบทั้ง 15 ข้อ + ปัญหาที่เจอ 7 ข้อ + รายการที่เหลือต้องทำด้วยมือ อยู่ที่ ** [`docs/acceptance-review.md` ](../acceptance-review.md )**
- สรุป: AC ทุกข้อผ่านในระดับตรวจอัตโนมัติ (backend ครอบทุก flow ด้วย integration tests จริง) + ฝั่ง Word for Mac ผ่านการทดสอบจริงตั้งแต่ Phase 5 · เหลือการยืนยันด้วยมือบน Word Online/Windows และ click-through บน browser จริง (ทำแทนไม่ได้)
2026-08-18 12:52:40 +07:00
---
2026-08-20 22:05:49 +07:00
## Phase 9: Version message — FR-13 (เพิ่มทีหลัง, v5)
**เป้า:** ใส่ "คำอธิบาย version" แบบ git commit message ได้ทุก flow ที่สร้าง version (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า — แผนเต็มที่ [`docs/plans/version-message.md` ](version-message.md )
- [x] Backend: `DocumentVersion.Message` (string?, varchar 2048) + `AppDbContext` config + migration `AddVersionMessage`
- [x] Backend: `UploadVersionRequest.Message` (multipart) + `GenerateDocumentRequest.Message` (JSON) + `VersionListItem.Message` + `NormalizeMessage` + BadRequest ตรวจความยาว 2048 ทั้ง upload/generate (กัน 500 + orphan MinIO object)
- [x] Add-in: `ConfirmVersionDialog` generalize (`title?/description?/confirmLabel?` + `onConfirm(message)` ) + textarea ทางเลือก + เคลียร์ state ตาม `open` · Start Versioning ใช้ dialog ด้วย (ตัวใหม่ใน App.tsx)
- [x] Add-in: `versionFlow` (`saveAndVersion` /`startVersioning` /`uploadCurrentFile` ) + `versioningService.uploadVersion` ส่ง `message` · `VersionRecord.message` + แสดงใน `VersionHistoryPanel`
- [x] Web: ฟอร์ม generate เพิ่ม textarea ทางเลือก + payload `message` · `VersionListItem.message` + แสดงใน history (desktop ใต้ v{N} truncate + tooltip, mobile เต็มบรรทัด)
- [x] Tests: `VersionMessageTests` (backend) + `versionFlow.test.ts` เพิ่ม 3 กรณี (แนบ/ว่างไม่แนบ/startVersioning)
หมายเหตุจากการทำจริง (2026-08-20):
- เพิ่ม param เป็น optional positional วางก่อน callback (callback-last) — test เดิมที่เรียก `saveAndVersion(id, 1)` / `startVersioning()` คอมไพล์ผ่านโดยไม่ต้องแก้
- `ConfirmVersionDialog` ถูก mount ค้างไว้นอก phase branch ของ App — เคลียร์ textarea ด้วย `useEffect` ตาม `open` ไม่ใช่ตอน unmount
- เลือกตรวจความยาว 2048 เป็น BadRequest เอง (ต่างจาก `Template.Description` ที่ปล่อยให้ DB reject) เพราะ Generate จะเกิด 500 **หลัง** MinIO upload แล้ว = orphan object ใน bucket
**DoD:** `dotnet test` + addin `npm test` + web `npm test` /`npm run build` เขียวทั้งชุด
> ✅ ตรวจผ่านจริง (2026-08-20): `dotnet test` **52/52** · addin `npm test` **50/50** · web `npm test` **36/36** + build ผ่าน
---
2026-08-22 09:17:15 +07:00
## Phase 10: Field editor — FR-14 (เพิ่มทีหลัง, v6)
**เป้า:** ผู้ดูแลเลือกรูปแบบช่องกรอก (editor) ต่อ field ได้ — `text` เลือก `textbox` /`textarea` ได้, `number` /`date` ใช้ control ตาม type — เป็น presentation metadata ล้วน (ค่า field เป็น string ทั้ง flow, validation ยึด `Type` เท่านั้น) — แผนเต็มที่ [`docs/plans/field-editor.md` ](field-editor.md )
- [x] Backend: `TemplateField.Editor` (string?, varchar 16 — null = default ของ Type) + `EditorsByType` /`DefaultEditor` + `AppDbContext` config + migration `AddTemplateFieldEditor` (nullable column เดี่ยว ไม่ backfill)
- [x] Backend: `TemplateFieldDefinition.Editor` (ทางเลือก — ไม่ส่ง/ว่าง = default) + validate ตาม type (ไม่รู้จัก/ไม่เข้ากับ type → 400) + normalize-on-write (ค่า = default → เก็บ null) + `TemplateFieldDto.Editor` คืนค่า resolved เสมอ
- [x] Web: util `fieldEditor.ts` (`EDITORS_BY_TYPE` /`defaultEditor` /`editorOptions` /`resolveEditor` /`dateInputValue` ) + `FieldEditor` type ใน `api.ts`
- [x] Web: หน้าแก้ field definitions เพิ่ม editor select (แสดงเฉพาะ type `text` ที่มีทางเลือก >1, number/date แสดง `—` ) + reset editor เป็น default เมื่อเปลี่ยน type + payload `editor`
- [x] Web: ฟอร์มสร้างเอกสาร render ตาม `resolveEditor` (textarea/number/date/textbox — preserve number/date control เดิม) + prefill re-generate ของ field date ผ่าน `dateInputValue` (input[type=date] รับเฉพาะ yyyy-MM-dd)
- [x] Tests: `FieldEditorTests` (backend 6 กรณี) + `fieldEditor.spec.ts` (web) + extend `TemplateTests` default/echo
หมายเหตุจากการทำจริง (2026-08-22):
- `applyDetail` ของ edit.vue resolve editor ตอนโหลด (`{ ...f, editor: resolveEditor(f) }` ) — ฟอร์มถือค่า valid เสมอแม้ API เก่า/ค่าหลุด
- `@change` ของ type select อ่านค่าจาก `e.target` ตรงๆ (`onTypeChange` ) ไม่พึ่งลำดับ handler ของ v-model
**DoD:** `dotnet test` + web `npm test` /`npm run build` เขียวทั้งชุด · generate/field sync path ไม่เปลี่ยนพฤติกรรม
> ✅ ตรวจผ่านจริง (2026-08-22): `dotnet test` **60/60** · web `npm test` **46/46** + build ผ่าน · addin ไม่ถูกแตะ (50/50 คงเดิม)
---
2026-08-18 12:52:40 +07:00
## 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 (เต็มรูปแบบ) |
2026-08-20 11:15:04 +07:00
| 6.4 (ทีหลัง, v4) | FR-12 | AC-15 |
2026-08-18 12:52:40 +07:00
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
| 8 | — | ทบทวน AC-1 ถึง AC-14 ทั้งหมด |
2026-08-20 22:05:49 +07:00
| 9 (ทีหลัง, v5) | FR-13 | AC-16 |
2026-08-22 09:17:15 +07:00
| 10 (ทีหลัง, v6) | FR-14 | AC-17 |
2026-08-18 12:52:40 +07:00
**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
2026-08-20 11:15:04 +07:00
- **AC-15:** (v4) หน้ารายการเอกสารของ template + หน้า detail (ค่าทุก field + version history ขยายเห็นค่าต่อ version + ดาวน์โหลด) + re-generate ได้เอกสารใหม่ v1 โดยเอกสารต้นทางไม่มี version ใหม่ — รายละเอียดตาม FR-12
2026-08-20 22:05:49 +07:00
- **AC-16:** (v5) พิมพ์คำอธิบายตอนสร้าง version ได้ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า · เว้นว่างได้ — รายละเอียดตาม FR-13
2026-08-22 09:17:15 +07:00
- **AC-17:** (v6) admin เลือก editor ของ field type `text` เป็น `textarea` ได้ → ฟอร์มสร้างเอกสารแสดงเป็น textarea · number/date ใช้ control ตาม type · editor ไม่ถูกต้อง → 400 · ไม่ส่ง → default ของ type · ค่า field และการ generate ไม่เปลี่ยน — รายละเอียดตาม FR-14
2026-08-18 12:52:40 +07:00
---
## 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 >
2026-08-22 09:17:15 +07:00
cd .. & & dotnet test # integration tests (60 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที)
2026-08-18 12:52:40 +07:00
# add-in (https://localhost:3000)
cd addin
npm install
npm run build # webpack build
npm start # dev server — sideload ผ่าน manifest นี้
2026-08-20 22:05:49 +07:00
npm test # jest (50 ตัว)
2026-08-18 12:52:40 +07:00
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว เพื่อให้ https localhost ใช้ได้
# web portal (http://localhost:5173 — ห้ามใช้ 3000 เพราะชน add-in)
cd web
npm install
npm run dev
2026-08-22 09:17:15 +07:00
npm test # vitest (46 ตัว)
2026-08-18 12:52:40 +07:00
```
2026-09-08 14:01:13 +07:00
## Phase 11: Version PDF + เลข version v0/v1+ — FR-15 (เพิ่มทีหลัง, v7)
| Phase | FR | AC |
|---|---|---|
| 11 (ทีหลัง, v7) | FR-15 (+แก้ FR-5/FR-7/FR-10) | AC-18 |
รายละเอียด: [version-pdf.md ](version-pdf.md ); ข้อความ generate=v1 ใน phase เดิมอ่านเป็น v0 ตาม spec v7
- [x] Spec v7 + glossary + ADR
- [x] Backend schema/API/storage + generate v0
- [x] Add-in PDF capture/warning/history
- [x] Web เปิด Word/PDF พร้อม fallback
- [x] Tests/build + QA reviewer PASS (2026-09-08; backend 68 / add-in 63 / web 55)
- [x] ตรวจ Word จริง — ผ่านบน Word for Mac (2026-09-08) รวม fidelity และ Track Changes (ไม่มี markup ปน); Word for Windows / Word on the web ค้างตามรายการเดิม
2026-09-10 10:51:13 +07:00
## Phase 12: ไฟล์ร่าง (draft) — FR-16 (เพิ่มทีหลัง, v8)
| Phase | FR | AC |
|---|---|---|
| 12 (ทีหลัง, v8) | FR-16 (+ขยาย FR-5) | AC-19 |
รายละเอียดเต็ม: [fr16-draft-generation-plan.md ](fr16-draft-generation-plan.md ) — decisions จาก grill session (stateless, stamp TemplateId ตัวเดียว, ผูก template ผ่าน form field `templateId` ตอน Start Versioning, deactivated ผูกได้, คง flow v0, ศัพท์ "ไฟล์ร่าง")
- [x] Spec v8 + glossary + ADR 0002
2026-09-10 11:49:16 +07:00
- [x] Backend: `POST /api/templates/{id}/generate-draft` (ตอน v8 คืนไฟล์ตรงๆ — ปรับใน v9) + `DocxGenerationService.GenerateDraft` + แยก `TemplateFieldValidation` + form field `templateId` ของ upload
2026-09-10 10:51:13 +07:00
- [x] Add-in: อ่าน `TemplateId` + ส่ง `templateId` เฉพาะ Start Versioning + hint ไฟล์ร่างใน task pane
2026-09-10 11:49:16 +07:00
- [x] Web: ปุ่ม "สร้างไฟล์ร่าง"
2026-09-10 10:51:13 +07:00
- [x] Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web)
- [x] QA reviewer PASS (2 rounds, 2026-09-10)
2026-09-10 12:22:19 +07:00
- [x] **v9 (feedback จากการใช้จริง): เปิดใน Word ทันที** — เก็บ object ใต้ `drafts/` + lifecycle รายวัน + คืน presigned URL + `useVersionOpen.openDocxUrl` · **แก้ตัดคำไทย** — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 85 / web 59) + QA reviewer PASS (2 rounds, 2026-09-10)
2026-09-10 13:59:45 +07:00
- [x] **v9.2: Word 16.101+ เมิน `ms-word:ofe` ทุกรูปแบบ (ทดสอบ 6 รอบบนเครื่องจริง)** → download-first (ดาวน์โหลดทันที + ยิง scheme ควบคู่เฉพาะ non-Mac โดย default — `NUXT_PUBLIC_OPEN_IN_WORD` override, เอา heuristic ออก) + `download-link` /draft ออก open-link ของ backend แทน presigned MinIO (`/api/open/{t}/{e}/...` HMAC ใน path ไม่มี query) — backend 86 / web 60
2026-09-10 13:39:23 +07:00
- [ ] ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → **ไฟล์ตก Downloads ทันที + ตัดคำไทยถูก** → เปิดไฟล์เอง → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web