52 KiB
Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
แผนงานสร้างระบบจาก spec:
specs/spec-officejs.mdสถานะ: Phase 0–3 เสร็จ (scaffold + infra + backend core + backend template & generation) — ต่อไป: phase 4–5 (add-in) หรือ phase 6 (web) ทำขนานกันได้ วันที่แผน: 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 clientword-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 ไหน:
- ห้ามออกแบบ flow ที่พึ่งการดัก native save (Ctrl+S / File>Save) — Office.js ไม่มี before-save event ที่ cancel ได้ ต้องใช้ explicit action (ปุ่มใน task pane / ribbon) เท่านั้น
- Custom Document Properties = primary flag สำหรับตรวจจับว่าไฟล์ผ่าน add-in นี้
- Custom XML Part = secondary storage เท่านั้น — แก้ไม่ได้ตรงๆ ต้อง
delete()แล้วadd()ใหม่ทุกครั้ง และมี known bug ที่อาจไม่ persist บน Mac บาง build → ระบบต้องทำงานถูกต้องแม้ XML part ล้มเหลวแบบ silent - ห้าม redirect iframe ของ task pane ไป Keycloak ตรงๆ (โดน
X-Frame-Options/CSP บล็อก) — ต้องใช้ Office Dialog API (displayDialogAsync) +messageParent; ส่วน/webเป็น top-level page ใช้ OIDC redirect ปกติได้ - Token เก็บใน memory เท่านั้น — ห้าม localStorage; ถ้าจำเป็นต้อง persist ใช้
sessionStorageอายุสั้น (ข้อยกเว้น: theme preference ของ/webเก็บ localStorage ได้เพราะไม่ใช่ข้อมูลรับรอง) - Backend คือ source of truth ของ version history และ field values เสมอ
- Config ทุกอย่าง (MinIO, Keycloak, DB) แยกเป็น env/
appsettings.jsonห้าม hardcode - ทุก Office.js call ต้อง catch และแสดง error ที่เข้าใจง่ายใน task pane — ห้ามปล่อย unhandled promise rejection
- Field key = content control (SDT) tag ต้อง unique ต่อ template — ใช้ tag เดียวกันทั้งตอน generate (backend), ตอน extract (add-in) และตอน sync (DB)
- Generation + metadata stamping ทำฝั่ง backend เท่านั้น (OpenXML SDK) เพื่อให้ v1 + custom properties เกิดพร้อมกันแบบ atomic — ไฟล์ที่โหลดจากเว็บถูกต้องสมบูรณ์ตั้งแต่ครั้งแรก ไม่ต้องพึ่ง client ตั้งค่าใดๆ
- ⚠️ 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 จริง
- ตรวจ prerequisites: Node LTS + Yeoman +
generator-office, .NET 8 SDK, Docker yo officeเลือก task pane, TypeScript, React → ย้าย output มาไว้ที่/addindotnet new webapi -n WordVersioning.Api→/backend/WordVersioning.Api- เพิ่ม packages:
Npgsql.EntityFrameworkCore.PostgreSQL,Microsoft.EntityFrameworkCore.Design,AWSSDK.S3,Microsoft.AspNetCore.Authentication.JwtBearer,DocumentFormat.OpenXml - 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) - สร้าง
.gitignore(root) ครอบทั้ง node_modules/bin/obj/.nuxt/env แล้วgit init - ปรับ
manifest.xmlฐานจาก yo office ให้ dev server ชี้https://localhost:3000
หมายเหตุจากการทำจริง (2026-08-18):
- คำสั่ง scaffold:
npx yo office react addin word --ts(generator-office ปัจจุบันใช้ projectTypereactแทน 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 สร้าง bucketdocx-versionsและdocx-templatesแบบ private ทั้งคู่keycloak—quay.io/keycloak/keycloakstart-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 URIhttps://localhost:3000/auth-callback.html - client
word-versioning-web— public, Standard Flow, PKCE S256, redirect URIhttp://localhost:5173/*(web ทุก route เพราะใช้ redirect ปกติ) - client
word-addin-vsto— public + PKCE (pre-register ตาม spec) - สร้าง test user แรกไว้ใน realm export เลยก็ได้
- realm
- กำหนด env defaults (เช่น ใน
.env.example): MinIO endpoint/keys, Keycloak admin password, DB password
หมายเหตุจากการทำจริง (2026-08-19):
- Keycloak =
26.2.5(tag26.2); admin bootstrap ใช้ envKC_BOOTSTRAP_ADMIN_USERNAME/PASSWORD(ชุดใหม่แทนKEYCLOAK_ADMINที่ deprecated) - Realm import strategy ของ Keycloak =
IGNORE_EXISTINGและ compose ไม่ mount volume เก็บข้อมูล realm โดยตั้งใจ → หลังdocker compose downrealm จะถูก 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 สำหรับทดสอบใช้ clientadmin-cliของ realm (ROPC) หรือเดิน auth code flow จริง - redirect URI ของ
word-addin-vstoใช้ loopbackhttp://localhost/*+http://127.0.0.1/*ตาม RFC 8252 (spec-vsto.md ยังไม่มี — pre-register ไว้ก่อน) apiservice ใส่ไว้ใน 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)
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/11Data/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+TemplatesBucketHashService— SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)
2.3 Auth (FR-8 ฝั่ง backend)
AddAuthentication(JwtBearer)configAuthority= 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-createDocumentถ้ายังไม่มี; 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)
หมายเหตุจากการทำจริง (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 จึง validateValidAudiences = [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)
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): 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 ถูก
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 (ใน bucketdocx-templates), IsActive (soft-delete), CreatedAt/UpdatedAtTemplateField— 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 (ทำเป็น composite PK)Documentเพิ่มTemplateId?+ migration (TemplatesAndFieldSync)
3.2 TemplatesController (FR-9)
GET /api/templates— list (filter active/all) +GET /api/templates/{id}(สำหรับหน้า edit เพิ่มเติมจากแผน)POST /api/templates— multipart: ไฟล์ .docx + name/description → upload ลง MinIO + parse SDT tags คืนเป็น field list ที่ detect ได้ (สร้าง TemplateField defaults ทันที: label=tag, type=text — admin ไปตั้งต่อผ่าน PUT)PUT /api/templates/{id}— แก้ metadata (name/description/isActive) + บันทึก field definitions (key ต้องเป็นชุดที่ detect ไว้ — เพิ่ม/ลบ key ต้องผ่าน replace ไฟล์)PUT /api/templates/{id}/file— แทนที่ไฟล์ (re-parse fields, response บอกaddedTags/removedTags)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 }:- Validate required + type
- Generate ไฟล์จาก template
- คำนวณ SHA256 + upload เป็น version 1 (
ObjectStorageService) - สร้าง
Document(TemplateId, FileName จาก template name + timestamp) +DocumentFieldValueทุก field +DocumentVersion.FieldsSnapshotJsonของ v1 - 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 ผูกอยู่ → updateDocumentFieldValue(latest value) + เขียนDocumentVersion.FieldsSnapshotJsonของ version นั้น - 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-inmetadataService) ต้องใช้ค่านี้เป๊ะ - OpenXML SDK 3.5.1: root ของ custom properties =
DocumentFormat.OpenXml.CustomProperties.Properties+CustomDocumentProperty(propertyName/FormatId/PropertyId+ childVTLPWSTR); 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 v2missing:true, field เกินถูก ignore; replace-file → addedTags/removedTags ถูก; deactivate 204 → generate 400 แต่ history/download ของ document เก่ายัง 200
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): ครบทั้ง 4 ข้อตามรายละเอียดในหมายเหตุข้างบน (ตรวจ formatting/custom properties ด้วย OpenXML + unzip
docProps/custom.xmlตามทางเลือกที่ DoD ยอมรับ — ส่วน "เปิดใน Word จริง" จะเกิดเต็มรูปแบบตอน AC-12 ใน Phase 6)
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— parsecode/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; exposeisTokenValid(),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-inversioningService—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.tsxstate machine หลัก:checking → signed-out → untracked → tracking- Sign-in gate (FR-1):
Office.onReady()→ เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค propertySavedByAddInเทียบ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 แบบเคร่งครัด:
- เปิด confirm dialog
- Stamp properties:
SavedByAddIn,DocumentTrackingId,LastVersionId,LastVersionTimestamp - อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
context.document.save(Word.SaveBehavior.save)fileServiceดึงไฟล์ปัจจุบัน +readContentControlFields()ดึงค่า fields ปัจจุบัน (FR-11)POST /api/documents/{documentId}/versions— แนบfieldsด้วย- แสดงผล + 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 ใน memoryuseApi.ts— fetch wrapper + Bearer + auto refresh ตอน 401useTheme.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/activatetemplates/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 จริง)
# 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