eCourt-Doc-Edit/docs/plans/officejs-implementation-plan.md
Suphonchai Phoonsawat 70f71f4c5c Phase 12 v9: draft opens in Word + Thai word-breaking fix (FR-16)
แก้ตามผลใช้งานจริง 2 ประเด็น:
1. เปิดใน Word ทันที — generate-draft เปลี่ยนจากคืนไฟล์ตรงๆ เป็นเก็บ
   ชั่วคราวใต้ drafts/ (lifecycle ลบรายวัน, config Minio__DraftRetentionDays)
   + คืน presigned URL ให้เว็บเปิดผ่าน ms-word:ofe พร้อมลิงก์สำรอง
   (useVersionOpen.openDocxUrl แยกแกนเปิด docx URL ใช้ร่วมกับ flow v0)
2. ตัดคำไทย — T14 แตก placeholder เป็น [ / ข้อความ / ] โดย run ข้อความ
   ถือ w:hint="cs" + w:cs เท่านั้น; เดิมเขียนค่าลง run ปีกกาทำให้ Word
   จัดหน้าไทยผิด — แก้เป็นเขียนที่ run ข้อความยาวสุด + SDT เปล่าสืบทอด
   rPr จาก sdtPr (ใช้ร่วมทั้ง generate และ generate-draft)

- ถอดของที่เลิกใช้: CORS expose Content-Disposition, downloadFile init,
  useApi.spec · Spec v9 + ADR 0002 Amendment + acceptance-review AC-19
- Tests: backend 84 / web 56 + build; QA reviewer PASS (2 rounds, 10 findings
  แก้ครบ — รวม bug จริงที่ test ใหม่จับได้: lifecycle Rules null ทำ startup
  ตั้ง rule ล้มแบบเงียบ) · ค้าง manual บน Word จริง

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:49:16 +07:00

96 KiB
Raw Blame History

Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)

แผนงานสร้างระบบจาก spec: specs/spec-officejs.md สถานะ: Phase 0–12 (Phase 0–11 เสร็จครบ; Phase 12 เหลือ manual บน Word จริง — backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ docs/acceptance-review.md) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)


1. ภาพรวม

ระบบ 3 ส่วนที่ทำงานร่วมกัน:

ส่วน เทคโนโลยี หน้าที่
Add-in (/addin) Office.js, TypeScript, React, Fluent UI, webpack (scaffold จาก yo office) Task pane ใน Word Desktop (Win/Mac) + Word Online — ตรวจจับ tracked document, ปุ่ม Save & Version, แสดง version history, จัดการ auth, extract fields จากเอกสาร
Backend (/backend/WordVersioning.Api) ASP.NET Core 8, EF Core, PostgreSQL, AWSSDK.S3 → MinIO, DocumentFormat.OpenXml เก็บ version metadata + ไฟล์ .docx ใน MinIO, validate JWT, ระบบ template + generate เอกสาร + field sync
Web Portal (/web) Nuxt.js + TailwindCSS (รองรับ light/dark theme) เว็บแยกสำหรับ (1) จัดการ template (2) กรอก form สร้างเอกสารจาก template → ได้ .docx ที่เป็น version 1 พร้อม metadata แล้ว
Auth Keycloak (OIDC) Public clients + PKCE (ทั้ง add-in และเว็บ), backend validate ผ่าน JWKS

ข้อตกลงที่ยืนยันแล้ว

  • Database = PostgreSQL (เลือกจาก spec ที่เปิดไว้ให้เลือก SQL Server หรือ PostgreSQL) — ใช้ provider Npgsql.EntityFrameworkCore.PostgreSQL
  • Scope = Office.js add-in + .NET backend + Web Portal ตามเอกสารนี้ (spec มีอ้างถึง spec-vsto.md แต่ไฟล์ยังไม่มี — แผนนี้เพียง pre-register Keycloak client word-addin-vsto ไว้ตามที่ spec กำหนด ไม่มีงาน VSTO)
  • หน้าจอสร้างเอกสารครั้งแรก = เว็บแยก (standalone) — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที
  • Template = หลาย template + หน้าจัดการเต็มรูปแบบ (CRUD + นิยาม field ต่อ template)
  • Field sync = เฉพาะ fields จาก template (ผูกกับ content controls) ไม่เก็บ full text
  • FR-9 ถึง FR-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

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
    eslint.config.mjs             # office-addin-lint ใช้ไฟล์นี้แทน config ของมัน (ปิด rule ที่ crash)
    jest.config.js
    /src
      /taskpane
        taskpane.html
        index.tsx                 # Office.onReady → FluentProvider + App
        auth-callback.html/.ts    # redirect target ของ Keycloak (รันใน Office Dialog)
        /components
          App.tsx                 # state machine: checking → signed-out → untracked → tracking
          VersionHistoryPanel.tsx
          ConfirmVersionDialog.tsx
      /services
        authService.ts            # PKCE + Dialog API + token in-memory + silent refresh
        metadataService.ts        # custom properties + custom XML part + content controls
        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
      /models
        versionRecord.ts
      /test                       # wordMock / httpMock / setup (jsdom polyfills)
    package.json
    webpack.config.js             # DefinePlugin: KEYCLOAK_AUTHORITY / API_BASE_URL จาก env
  /scripts
    dev-token.py                  # เดิน PKCE จริงได้ token ทดสอบ backend (admin-cli ไม่มี aud)
  /backend                        # Phase 0, 2, 3
    /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
    /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`
  /web                            # Phase 0, 6
    nuxt.config.ts                # devServer port 5173
    tailwind.config.ts            # darkMode: 'class'
    /pages
      index.vue                   # หน้าหลัก: เลือก template เพื่อสร้างเอกสาร
      auth/callback.vue           # ปลายทาง OIDC redirect (แลก token แล้วพากลับ)
      templates/index.vue         # รายการ template (admin)
      templates/new.vue           # upload template + ตั้งค่า field defs
      templates/[id]/edit.vue     # แก้ไข template + field defs
      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)
    /composables
      useAuth.ts                  # OIDC PKCE + browser redirect ปกติ
      useApi.ts                   # fetch wrapper + Bearer token + refresh
      useTheme.ts                 # light/dark toggle + persist + system default
    /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
    /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 มาไว้ที่ /addin
  • dotnet 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 ปัจจุบันใช้ projectType react แทน flag --framework react)
  • .NET packages ที่รองรับ net8.0 ต้องตรึง --version 8.0.11 (Npgsql.EFCore / EFCore.Design / JwtBearer) เพราะ latest (10.x) รองรับเฉพาะ net10.0; AWSSDK.S3 4.0.102.1 + DocumentFormat.OpenXml 3.5.1 ใช้ latest ได้
  • Nuxt เวอร์ชันที่ได้ = 4.5.2 → โครง app/ directory (app/pages, app/composables, app/assets/css) แทน root-level ตามแผนเดิม — ความหมายเดียวกัน
  • ทดสอบ DoD ผ่านทั้งสามส่วน (webpack compiled successfully / Build succeeded 0 errors / หน้าแรก render ที่ 5173 พร้อม dark: variants แบบ class strategy)

Deliverable: โครง 3 โปรเจกต์ตาม section 3

DoD: npm run build ใน /addin ผ่าน + dotnet build ใน /backend ผ่าน + npm run dev ใน /web เปิดหน้าแรกได้ที่ http://localhost:5173 พร้อม toggle light/dark ทำงาน


Phase 1: Local dev infrastructure (docker-compose + Keycloak + MinIO)

เป้า: รัน infra ทั้งชุดด้วยคำสั่งเดียว — ตรง spec ส่วน 11 (backend ข้อ 6, 8)

  • docker-compose.yml กำหนด services:
    • db — postgres:16 (volume สำหรับข้อมูล)
    • minio — minio/minio (console เปิดด้วย) + init service สร้าง bucket docx-versions และ docx-templates แบบ private ทั้งคู่
    • keycloak — quay.io/keycloak/keycloak start-dev + import realm อัตโนมัติ
    • api — build จาก /backend (รอ Phase 2 เสร็จค่อย enable ก็ได้)
  • /keycloak/realm-export.json:
    • realm word-versioning
    • client word-addin-officejs — public, Standard Flow, PKCE S256 บังคับ, ไม่มี secret, redirect URI https://localhost:3000/auth-callback.html
    • client word-versioning-web — public, Standard Flow, PKCE S256, redirect URI http://localhost:5173/* (web ทุก route เพราะใช้ redirect ปกติ)
    • client word-addin-vsto — public + PKCE (pre-register ตาม spec)
    • สร้าง test user แรกไว้ใน realm export เลยก็ได้
  • กำหนด env defaults (เช่น ใน .env.example): MinIO endpoint/keys, Keycloak admin password, DB password

หมายเหตุจากการทำจริง (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)

DoD: docker compose up แล้ว (1) เข้า Keycloak admin console เห็น realm word-versioning + clients ครบ 3 ตัว (2) เข้า MinIO console เห็น bucket docx-versions + docx-templates ที่เป็น private


Phase 2: Backend core — FR-5, FR-6, FR-7, FR-8 (ฝั่ง server)

เป้า: API ครบทุก endpoint ของ spec เดิม พร้อม auth + storage จริง — ตรง spec ส่วน 11 (backend ข้อ 1–5)

2.1 Data layer

  • Models/Document.cs, Models/DocumentVersion.cs ตาม spec ส่วน 7 + เพิ่ม TemplateId (Document) และ FieldsSnapshotJson (DocumentVersion) สำหรับ FR-10/11
  • Data/AppDbContext.cs (Npgsql) + unique index (DocumentId, VersionNumber)
  • EF migration แรก

2.2 Services

  • IObjectStorageService + ObjectStorageService — AmazonS3Client ตั้ง ServiceURL = MinIO endpoint, ForcePathStyle = true (จำเป็นสำหรับ MinIO), methods: PutAsync(bucket, key, stream, contentType), GetStreamAsync(bucket, key), config (Endpoint, AccessKey, SecretKey, UseSSL) จาก appsettings.json/env; BucketName แยกเป็น VersionsBucket + TemplatesBucket
  • HashService — SHA256 คำนวณตอน stream ผ่าน (ไม่ต้อง load ทั้งไฟล์ใน memory ถ้าไหว)

2.3 Auth (FR-8 ฝั่ง backend)

  • AddAuthentication(JwtBearer) config Authority = Keycloak realm URL, Audience ครอบทั้ง client ของ add-in และ web (หรือใช้ realm-level validation ตามที่ config ได้), RequireHttpsMetadata = false (dev เท่านั้น — คอมเมนต์ไว้ชัดว่า prod ต้อง true)
  • [Authorize] บน DocumentsController และ TemplatesController ทั้ง controller
  • Helper ดึง userId/username จาก claims sub / preferred_username — ห้ามรับค่านี้จาก client

2.4 Endpoints (DocumentsController)

  • POST /api/documents/{documentId}/versions (FR-5) — multipart: file + clientVersionId + fields (JSON map tag→text, เตรียมทางไว้ให้ FR-11); auto-create Document ถ้ายังไม่มี; versionNumber = max+1 ต่อ document (มี unique index รองรับ concurrency); object key {documentId}/v{versionNumber:D4}_{clientVersionId}.docx; คำนวณ + เก็บ SHA256; response { versionId, versionNumber }
  • GET /api/documents/{documentId}/versions (FR-6) — เรียงใหม่ → เก่า (คืน snapshot fields ต่อ version ด้วย)
  • GET /api/documents/{documentId}/versions/{versionId}/download (FR-7) — proxy stream จาก MinIO (ไม่ expose MinIO ตรงๆ) พร้อม content-type ที่ถูกต้อง
  • Validation: reject content-type ที่ไม่ใช่ docx, จำกัด request size 100MB (RequestSizeLimit / Kestrel config)

2.5 Cross-cutting

  • CORS อนุญาต https://localhost:3000 (add-in) และ http://localhost:5173 (web)
  • Logging ทุก upload/download/generate: documentId, versionId, userId
  • appsettings.json + env overrides ครบทุก config (DB, MinIO, Keycloak)

หมายเหตุจากการทำจริง (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)

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 (ใน bucket docx-templates), IsActive (soft-delete), CreatedAt/UpdatedAt
  • TemplateField — Id, TemplateId, Key (= SDT tag), Label, Type (text|number|date), IsRequired, SortOrder; unique (TemplateId, Key)
  • DocumentFieldValue — DocumentId, FieldKey, Value (string), UpdatedAt, UpdatedByUserId, SourceVersionId; unique (DocumentId, FieldKey) = latest value ต่อ field (ทำเป็น 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 }:
    1. Validate required + type
    2. Generate ไฟล์จาก template
    3. คำนวณ SHA256 + upload เป็น version 1 (ObjectStorageService)
    4. สร้าง Document (TemplateId, FileName จาก template name + timestamp) + DocumentFieldValue ทุก field + DocumentVersion.FieldsSnapshotJson ของ v1
    5. Response { documentId, versionId, versionNumber, downloadUrl }

3.4 Field sync บน upload endpoint (FR-11 ฝั่ง backend)

  • ขยาย POST /api/documents/{documentId}/versions: รับ fields (map tag→text) → เทียบกับ TemplateField ของ template ที่ document ผูกอยู่ → update DocumentFieldValue (latest value) + เขียน DocumentVersion.FieldsSnapshotJson ของ version นั้น
  • field ที่ส่งมาขาด (ผู้ใช้ลบ content control) → เก็บค่าเดิม + mark missing ใน snapshot ไม่ fail request; field ที่ส่งมาเกินนิยาม → ignore

หมายเหตุจากการทำจริง (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

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 — parse code/state จาก URL, Office.context.ui.messageParent(JSON.stringify({code, state})) แล้ว window.close()
  • แลก token ที่ Keycloak token endpoint ด้วย verifier (public client ไม่พึ่ง backend)
  • เก็บ access_token/refresh_token ใน JS variable ของ task pane session; expose isTokenValid(), getAccessToken()
  • Silent refresh ก่อนหมดอายุ + ถ้า refresh token ตาย/ถูก revoke → คืนสถานะ "ต้อง login ใหม่" แบบ graceful (ไม่ error ค้าง)
  • ป้องกัน CSRF ด้วยการเทียบ state

4.2 Metadata (metadataService.ts)

  • อ่าน Custom Properties: SavedByAddIn, DocumentTrackingId, LastVersionId, LastVersionTimestamp
  • เขียน/stamp properties (ใช้ property add() ของใหม่ทับ — ไม่มีปัญหาแบบ XML part)
  • XML part ฝั่งอ่าน: หา part ที่ namespace urn:myaddin:versioning → parse เป็น list
  • XML part ฝั่งเขียน: delete ของเดิมทั้งหมดก่อนค่อย add() ใหม่ + catch error แบบไม่ throw (known bug บน Mac — เขียนไม่ได้ก็ปล่อยผ่านเพราะ backend คือ source of truth)
  • Constants: ADDIN_SIGNATURE_GUID
  • (FR-11) readContentControlFields() — Word.run → context.document.contentControls → load("tag, text") → คืน { [tag]: text }; เอกสารที่ไม่มี content controls → คืน {} ไม่ error

4.3 File + API (fileService.ts, apiClient.ts, versioningService.ts)

  • fileService — getFileAsync(Office.FileType.Compressed) → อ่าน slice ทีละชิ้น → ประกอบเป็น Blob → เรียก closeAsync เสมอ (จำกัดจำนวน file object ที่เปิดพร้อมกันได้)
  • apiClient — wrapper fetch ที่แนบ Authorization: Bearer <token> ทุก request; โดน 401 → สั่ง refresh token แล้ว retry ครั้งเดียว; ยัง 401 → ส่งสัญญาณให้ UI กลับไปหน้า sign-in
  • versioningService — uploadVersion(documentId, blob, clientVersionId, fields), getVersions(documentId), getDownloadUrl(...)/download — fields = map จาก readContentControlFields()
  • models/versionRecord.ts — type ตรงกับ response ของ backend (รวม snapshot fields ต่อ version)

4.4 Manifest

  • manifest.xml ประกาศ requirement sets ให้ครอบ WordApi 1.5 (customProperties, customXmlParts, document.save()) + DialogAPI

หมายเหตุจากการทำจริง (2026-08-19):

  • customXmlParts อยู่บน context.document ไม่ใช่ context (ตาม @types/office-js — WordApi 1.4) — ใช้ context.document.customXmlParts.getByNamespace(...) / .add(...)
  • Config inject ผ่าน webpack DefinePlugin (KEYCLOAK_AUTHORITY / KEYCLOAK_CLIENT_ID / API_BASE_URL จาก env ตอน build, default dev = Keycloak 8080 / backend 5075) — อ่านที่ src/config.ts; redirectUri derive จาก window.location.origin + "/auth-callback.html" ให้ตรง realm-export เสมอ
  • auth-callback.html ต้องอยู่ root ของ dist ตรง redirect URI — เพิ่ม entry authCallback + HtmlWebpackPlugin แยกใน webpack.config.js (template src/taskpane/auth-callback.html + logic ใน auth-callback.ts, ไม่ใช่ React)
  • Jest ใช้ babel-jest (คอมไพล์ด้วย babel.config.json เดิม) ไม่ใช่ ts-jest — ts-jest@29 peerOptional ไม่รับ @babel/core 8 ของ scaffold; ผลข้างเคียง: test ไม่ type-check ตอนรัน (type ตรวจที่ npm run build ผ่าน ts-loader ซึ่งไล่ทั้ง program รวม *.test.ts แล้ว)
  • jsdom env ไม่มี Response/fetch/crypto.subtle และ crypto เป็น getter-only (assign ตรงๆ เงียบ) → src/test/setup.ts ต้อง Object.defineProperty แล้วดึง webcrypto จาก node; mock HTTP จึงเป็น duck-typing object ไม่ใช่ Response จริง (ดู src/test/httpMock.ts)
  • test ที่รอ dialog ต้อง poll จน displayDialogAsync ถูกเรียก แทน setTimeout(0) — บนเครื่องโหลด crypto.subtle.digest อาจยังไม่จบภายใน macrotask เดียว (มิฉะนั้น message หลุดก่อน handler ลงทะเบียน)
  • tsconfig เพิ่ม types จำกัด (office-js, office-runtime, react, react-dom, jest, node) — ไม่อย่างนั้น @types/babel__core ที่ jest/babel-jest ดึงเข้ามาชนกับ @babel/types v8 จน tsc พัง
  • eslint: สร้าง eslint.config.mjs ของโปรเจกต์ (office-addin-lint จะใช้แทน config ของมันถ้ามีไฟล์นี้ใน root) เพราะ rule no-navigational-load ของ plugin 4.0.10 (a) crash กับ context.load(obj) แบบ 1 อาร์กิวเมนต์ (b) false positive กับ Word properties เช่น tag — ปิด rule นี้ + ปิด no-undef/no-redeclare สำหรับ .ts/.tsx (tsc ตรวจแทนแม่นกว่า)
  • manifest: element Requirements วางระหว่าง </Hosts> กับ <DefaultSettings> — npm run validate ผ่าน และ platform analysis ยังครบ (Word desktop/web/iPad ทุกรุ่นรองรับ WordApi 1.5 + DialogAPI 1.1)
  • readTrackedProperties ใช้ getItemOrNullObject(...).load("value") แล้วอ่าน isNullObject หลัง sync (null-object proxy ต้องถูก load อย่างน้อย 1 property ค่า isNullObject จึงแม่น)
  • Services ยังไม่ถูก import เข้า taskpane entry (เป็นงาน Phase 5) — ครั้งแรกที่จะรันจริงใน Word คือตอน wire UI

DoD: npm run build ผ่าน + unit tests ผ่าน (Jest: mock Word.run, Office.context.ui, fetch, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)

✅ ตรวจผ่านจริง (2026-08-19): npm run build compiled successfully (0 errors), npm test 40/40 ผ่านทั้ง 4 ชุด (authService 14, metadataService 14, apiClient 8, fileService 4 รวมกรณี slice พังกลางทาง→ยัง closeAsync, closeAsync พัง→ยังได้ blob), npm run lint ผ่าน, npm run validate (manifest) ผ่าน

DoD: npm run build ผ่าน + unit tests ผ่าน (Jest: mock Word.run, Office.context.ui, fetch, Keycloak token response) — ครอบ PKCE flow, delete-then-add XML part, apiClient retry-on-401, fileService ประกอบ slice, readContentControlFields แปลง contentControls → map ถูกต้อง (รวมกรณี control ซ้ำ tag → เอาตัวแรก + tag ว่าง → ข้าม)


Phase 5: Task pane UI — FR-1, FR-2, FR-3, FR-4, FR-11 (ส่วนส่งค่า)

เป้า: UI ครบทุก flow — ตรง spec ส่วน 11 (frontend ข้อ 4)

  • taskpane.tsx state machine หลัก: checking → signed-out → untracked → tracking
  • Sign-in gate (FR-1): Office.onReady() → เช็ค token ก่อน — ไม่มี/หมดอายุ → หน้า "Sign in" (ปุ่มเรียก authService); มี → เช็ค property SavedByAddIn เทียบ ADDIN_SIGNATURE_GUID
    • ตรง → อ่าน DocumentTrackingId → getVersions() → เข้าสถานะ tracking (ก็คือ flow ของไฟล์ที่สร้างจากเว็บ — เปิดปุ๊บเห็น v1 ทันที ไม่ต้อง Start Versioning)
    • ไม่ตรง → ปุ่ม "Start Versioning This Document"
  • Start Versioning: stamp metadata รอบแรก (SavedByAddIn, DocumentTrackingId ใหม่) → upload version แรก → สลับเป็น tracking
  • ConfirmVersionDialog.tsx — ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?"
  • Save & Version flow (FR-2) ตามลำดับ spec แบบเคร่งครัด:
    1. เปิด confirm dialog
    2. Stamp properties: SavedByAddIn, DocumentTrackingId, LastVersionId, LastVersionTimestamp
    3. อัปเดต XML part (delete-then-add, fail ได้แบบ silent)
    4. context.document.save(Word.SaveBehavior.save)
    5. fileService ดึงไฟล์ปัจจุบัน + readContentControlFields() ดึงค่า fields ปัจจุบัน (FR-11)
    6. POST /api/documents/{documentId}/versions — แนบ fields ด้วย
    7. แสดงผล + refresh history
    • ทุก step มี loading state + error message + ห้ามปล่อย unhandled rejection
  • VersionHistoryPanel.tsx (FR-4) — list version number/timestamp/ผู้สร้าง + ปุ่ม "Download this version" (GET .../download)
  • Soft reminder (FR-3): polling/debounce ทุก ~30 วินาทีเช็ค context.document.saved — false ต่อเนื่องเกิน threshold → banner "มีการแก้ไขที่ยังไม่ได้ version" (ไม่ block ใคร)
  • จัดการ token หมดอายุกลาง session: 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in อย่างเนียน (AC-9)
  • Fluent UI components ให้หน้าตาเข้ากับ Office

หมายเหตุจากการทำจริง (2026-08-19):

  • state machine อยู่ที่ src/taskpane/components/App.tsx (yo office ให้โครง index.tsx + App.tsx — ไม่มี taskpane.tsx ตามชื่อในแผน) และ orchestration ของ Start Versioning / Save & Version แยกเป็น services/versionFlow.ts เพื่อ unit test ได้ (mock ทั้ง Word/Office/fetch) — npm test ขยายเป็น 45 ตัว
  • LastVersionId ในไฟล์ = clientVersionId (GUID ที่ client generate) ไม่ใช่ versionId ของ backend — เพราะต้อง stamp ลง properties ก่อน document.save() จึงจะไปอยู่ในไฟล์ที่อัปโหลด (backend versionId ได้มาหลังไฟล์ถูก capture ไปแล้ว) · ส่วน entry ใน XML part เดาเลข version จาก history ล่าสุด+1 ก่อน upload ถ้า backend ตอบเลขต่าง (มี client อื่นแทรก) จะเขียน XML part แก้ให้ตรงแบบ best-effort
  • เปิด strict ใน tsconfig ของ addin — พบว่า tsconfig ของ scaffold ปิด strictNullChecks ซึ่งทำให้ boolean-literal discriminated union ({ok:true} | {ok:false; error}) narrow ไม่ได้ (compile error) — เปิดแล้วมี error เพิ่มแค่ 4 จุดใน test (แก้แล้ว)
  • ชื่อไฟล์ตอน upload ได้จาก Office.context.document.url (desktop มักมี path จริง) fallback {documentId}.docx (Word Online อาจไม่มี URL)
  • FR-3: poll ทุก 30 วิ เฉพาะ phase tracking อ่าน context.document.saved — fail รอบใดข้ามรอบนั้น (banner เป็น soft reminder เท่านั้น)
  • ดาวน์โหลด version: fetch เป็น blob (ต้องแนบ Bearer) → URL.createObjectURL + anchor download — ชื่อไฟล์ document_v{N}.docx
  • ลบ scaffold demo ออก (Header/HeroList/TextInsertion/taskpane.ts) — task pane เป็นหน้าจริงของระบบแล้ว
  • ⚠️ DoD ส่วน sideload ต้องตรวจด้วยมือใน Word จริง (Claude รัน Word ไม่ได้) — ขั้นตอน: รัน infra + backend + npm start แล้ว sideload manifest.xml บน Word Desktop/Online แล้วไล่ checklist AC-1, 2, 4–8, 13 (ส่วนที่ automate ได้คือ build/test/lint/validate ซึ่งผ่านทั้งหมดแล้ว)
  • 🩹 แก้จาก sideload test จริงรอบแรก: กด Sign in แล้วโดน "The HTTP protocol is not supported. Use HTTPS instead" — Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์ ทั้งที่ Keycloak dev เดิมรัน http://localhost:8080 → แก้โดยให้ Keycloak เสิร์ฟ HTTPS ที่ 8443 ด้วย cert ของ office-addin-dev-certs (mount ${HOME}/.office-addin-dev-certs เข้า container + KC_HTTPS_CERTIFICATE_FILE/KEY_FILE) · 8080 เหลือไว้เฉพาะภายใน network ของ compose (ให้ service api ใช้) · ค่าที่ตามไปเปลี่ยน: backend Authentication:Authority → https://localhost:8443/realms/word-versioning, add-in webpack default KEYCLOAK_AUTHORITY → เดียวกัน, README/.env.example/test constants ทั้งหมด · ตรวจจริงครบ: discovery ผ่าน (issuer = https…8443), PKCE flow จริงได้ token ที่มี aud ถูกต้อง, backend ตอบ 200 · ผลพลอยได้: เจอว่าตัวอย่าง curl admin-cli เดิมเรียก backend ไม่ผ่าน (token ไม่มี aud) → เพิ่ม scripts/dev-token.py (เดิน PKCE จริง ได้ token ใช้ทดสอบ backend/Swagger) และแก้ README ให้ใช้แทน
  • 🩹 แก้จาก sideload test จริงรอบสี่: กดดาวน์โหลด version แล้วไฟล์ไม่ไปไหนเลย (ค้น ~/Downloads + Word container + Spotlight ไม่เจอ) — WKWebView ของ Word ทิ้ง blob download (a[download] + object URL) แบบเงียบๆ → เปลี่ยนวิธีเป็น "ขอ presigned URL อายุสั้นแล้วเปิดในเบราว์เซอร์ของเครื่อง": backend เพิ่ม GET .../download-link (MinIO presigned GET · อายุ config MinIO:PresignedExpirySeconds default 120s clamp 30–600 — spec ส่วน 9 อนุญาต presigned URL อายุสั้น) · add-in ใช้ Office.context.ui.openBrowserWindow (manifest เพิ่ม OpenBrowserWindowApi 1.1) โดยมี blob เดิมเป็น fallback บน host ที่ไม่มี API นี้ · จุดเจาะจง: AWSSDK.S3 v4 บังคับ https ใน presigned URL เสมอ (พิสูจน์ด้วยโปรเจกต์ทดสอบแยก — UseHttp ไม่ช่วย) แต่ SigV4 เซ็นค่า host header ไม่รวม scheme จึงสลับ https→http หลังเซ็นตาม config UseSSL ได้ · ตรวจจริง: upload → ขอ link → curl presigned URL ได้ไบต์ตรงเป๊ะ ✓ · อื่นๆ รอบนี้: ลบ manifest.json ทิ้ง (unified manifest ฝั่ง Outlook จาก scaffold — ไม่ได้ใช้ แก้แล้วไม่มีผลกับ Word) และเปลี่ยนชื่อเป็น e-Court Documents Versioning ทั้งใน manifest.xml (DisplayName/group/ปุ่ม) และหัว task pane · ⚠️ อย่า cp ทับ manifest ใน wef folder — office-addin-debugging สร้าง symlink ไว้ การ cp ทับทำให้กลายเป็นไฟล์ธรรมดา แล้ว npm start ครั้งถัดไปจะ EEXIST — ให้แก้ที่ addin/manifest.xml ต้นทางแล้ว restart Word
  • 🩹 แก้จาก sideload test จริงรอบสาม: เข้า state tracking ได้แล้ว แต่ทุก API call โดน "Load failed" — WKWebView ของ Word บน Mac บล็อก fetch จากหน้า https ไป http แบบเงียบๆ (mixed content/ATS): task pane เป็น https://localhost:3000 ส่วน API เดิมเป็น http://localhost:5075 (login ผ่านเพราะ Keycloak เป็น https แล้ว · CORS ตรวจแล้วสมบูรณ์ ไม่ใช่ต้นเหตุ) → แก้ด้วยวิธีเดียวกับ Keycloak: backend เปิด endpoint https://localhost:7232 ด้วย dev cert ชุดเดียวกัน — Program.cs เพิ่ม ConfigureKestrel (เปิดเฉพาะ Development + เจอ cert, config DevHttps:* ใน appsettings.Development.json, Listen ทั้ง 5075/7232 แทน URL จาก launchSettings) · จุดเจาะจง: key ของ office-addin-dev-certs เป็น PKCS#1 ซึ่ง UseHttps(cert, key) overload ไม่รับ → โหลดเองด้วย RSA.ImportFromPem + CopyWithPrivateKey + export PFX in-memory (macOS ต้องทำแบบนี้ SslStream ถึงใช้ key ได้) · add-in API_BASE_URL default → https://localhost:7232 · ตรวจจริง: รัน temp instance แล้ว curl ผ่าน https ด้วย dev-token.py ได้ 200 · สรุปกติกา dev: ทุก origin ที่หน้า https จะเรียก (Keycloak 8443, backend 7232) ต้องเป็น https ด้วย cert จาก office-addin-dev-certs ทั้งหมด
  • 🩹 แก้จาก sideload test จริงรอบสอง: login ผ่านแล้ว กด Start Versioning โดน "รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)" — host แต่ละตัวส่ง slice.data กลับคนละรูป (เอกสารระบุลอยๆ ว่า "byte array"): Word Win = ArrayBuffer/view แต่ Word Mac ส่งมาเป็นอย่างอื่น (base64 string / number[]) → sliceDataToBytes() ใน fileService รองรับครบทั้ง 4 รูปแบบ + เติม test ครบทุกรูป (47 ตัว) · อีกเรื่องที่เจอ: restart npm start ไม่ได้แทน dev-server ตัวเก่า (port 3000 ถูกถือครอง → ตัวเก่ายัง serve bundle เก่า) — ต้อง kill ตัวเก่าก่อนเสมอ · และ webpack ไม่ restart เองเมื่อ webpack.config.js เปลี่ยน (DefinePlugin ค่าเก่าติดไปกับ bundle เก่า) — แก้ config แล้วต้อง restart dev-server ทุกครั้ง

DoD: sideload บน Word Desktop (Mac ที่มีอยู่ + Windows ถ้ามี) และ Word Online → ครบ flow: login ผ่าน dialog, start versioning, save & version, เห็น history, download ไฟล์ย้อนหลังเปิดได้ปกติ (AC-1, 2, 4, 5, 6, 7, 8 ผ่าน) + แก้ค่าใน content control แล้ว Save & Version → DB field values เปลี่ยนตาม (AC-13) + เปิดไฟล์ที่ generate จากเว็บ (Phase 6 เสร็จ) → เห็นเป็น tracked + v1 ทันที (AC-12)

🟡→ อัปเดต (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


Phase 6: Web Portal — FR-9 (UI), FR-10 (UI) (ใหม่)

เป้า: เว็บ Nuxt + Tailwind สำหรับจัดการ template และสร้างเอกสาร ทุกหน้ารองรับ light/dark theme

6.1 พื้นฐาน

  • useAuth.ts — OIDC PKCE + browser redirect ปกติ (top-level page ใช้ได้เลย ไม่ต้องมี Dialog API), รับ code ที่ route callback, แลก token, silent refresh, token ใน memory
  • useApi.ts — fetch wrapper + Bearer + auto refresh ตอน 401
  • useTheme.ts — toggle light/dark (สลับ class บน <html>), persist ใน localStorage, default ตาม prefers-color-scheme
  • Layout หลัก: header + theme toggle + สถานะ login (ทุกหน้าใช้ร่วม)

6.2 จัดการ template (FR-9 UI)

  • templates/index.vue — ตารางรายการ (name, description, จำนวน fields, active) + ปุ่ม deactivate/activate
  • templates/new.vue + templates/[id]/edit.vue — upload/แทนที่ไฟล์ .docx → โชว์ field list ที่ระบบ detect อัตโนมัติ → ฟอร์มตั้ง label/type (text/number/date)/required/ลำดับ → บันทึก

6.3 สร้างเอกสาร (FR-10 UI)

  • index.vue — card รายการ template ที่ active → เลือกแล้วไปหน้าสร้าง
  • documents/create/[templateId].vue — dynamic form สร้างจาก field defs (input type ตาม field type: text/number/date picker), validate required/type ฝั่ง client + แสดง error จาก backend
  • กดสร้าง → แสดงผล: documentId, version 1 + ปุ่มดาวน์โหลด .docx + คำแนะนำสั้นๆ ("เปิดไฟล์นี้ใน Word แล้วใช้ add-in ต่อ")

หมายเหตุจากการทำจริง (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 ได้ทันที และลบผ่านหน้าเว็บได้เอง

6.4 รายการเอกสารต่อ template + Document detail + Re-generate — FR-12 (เพิ่มทีหลัง, v4)

  • Backend GET /api/templates/{templateId}/documents — รายการเอกสารของ template (ชื่อไฟล์, versionCount, version ล่าสุด + เวลา) เรียงกิจกรรมล่าสุดก่อน, 404 เมื่อ template ไม่มี, ใช้ได้แม้ deactivate
  • Backend GET /api/documents/{documentId} — detail: หัวเรื่อง + template + ค่าล่าสุดทุก field (merge TemplateField defs × DocumentFieldValue แบบ 3 query ใน memory — รองรับ orphan สองทาง: def ใหม่ไม่มีค่า / ค่าเก่าไม่มี def)
  • templates/[id]/documents.vue — ตารางเอกสารของ template + ลิงก์เข้า detail (จุดเข้า: ปุ่ม "เอกสาร" ในตาราง template + ลิงก์บน card หน้าหลัก)
  • documents/[documentId].vue — fields ปัจจุบัน + version history ขยายแถวเห็น snapshot ต่อ version (normalizeSnapshot รองรับ snapshot ทั้ง 2 รูปแบบ) + ดาวน์โหลดต่อ version
  • ปุ่ม 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

DoD: login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload

🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ฝั่ง backend/generate คุ้มแล้วด้วย integration tests (custom properties + v1 ครบ) — การยืนยันบน Word จริงอยู่ใน checklist งานมือท้าย docs/acceptance-review.md (ตอนนั้นยังต้องรอ Phase 8 ตอนนี้ไล่ครบแล้ว)


Phase 7: Tests

เป้า: ความมั่นใจก่อนเรียกว่าเสร็จ — ตรง spec ส่วน 11 (frontend ข้อ 7, backend ข้อ 7) + ครอบ features ใหม่

  • Frontend unit (Jest, ทำพร้อม Phase 4–5): — 47 tests ผ่าน
    • 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 (ครบทั้ง 4 รูปแบบของ slice.data), เรียก closeAsync
  • 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
  • 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 จริง)

DoD: npm test (addin), npm test (web) และ dotnet test เขียวทั้งคู่

✅ ตรวจผ่านจริง (2026-08-20): cd backend && dotnet test 42/42 (Testcontainers ยก infra จริงครบทุกครั้ง) · addin npm test 47/47 · web npm test 36/36 — รวม 125 ตัวเขียวทั้งชุด


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) — ✅ เติมครบแล้ว (2026-08-20) รวม web portal walkthrough ทุกหน้า + re-generate + troubleshooting
  • ไล่ checklist acceptance criteria ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ — ✅ (2026-08-20) ผลครบทั้ง 15 ข้อ + ปัญหาที่เจอ 7 ข้อ + รายการที่เหลือต้องทำด้วยมือ อยู่ที่ docs/acceptance-review.md
    • สรุป: AC ทุกข้อผ่านในระดับตรวจอัตโนมัติ (backend ครอบทุก flow ด้วย integration tests จริง) + ฝั่ง Word for Mac ผ่านการทดสอบจริงตั้งแต่ Phase 5 · เหลือการยืนยันด้วยมือบน Word Online/Windows และ click-through บน browser จริง (ทำแทนไม่ได้)

Phase 9: Version message — FR-13 (เพิ่มทีหลัง, v5)

เป้า: ใส่ "คำอธิบาย version" แบบ git commit message ได้ทุก flow ที่สร้าง version (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า — แผนเต็มที่ docs/plans/version-message.md

  • Backend: DocumentVersion.Message (string?, varchar 2048) + AppDbContext config + migration AddVersionMessage
  • Backend: UploadVersionRequest.Message (multipart) + GenerateDocumentRequest.Message (JSON) + VersionListItem.Message + NormalizeMessage + BadRequest ตรวจความยาว 2048 ทั้ง upload/generate (กัน 500 + orphan MinIO object)
  • Add-in: ConfirmVersionDialog generalize (title?/description?/confirmLabel? + onConfirm(message)) + textarea ทางเลือก + เคลียร์ state ตาม open · Start Versioning ใช้ dialog ด้วย (ตัวใหม่ใน App.tsx)
  • Add-in: versionFlow (saveAndVersion/startVersioning/uploadCurrentFile) + versioningService.uploadVersion ส่ง message · VersionRecord.message + แสดงใน VersionHistoryPanel
  • Web: ฟอร์ม generate เพิ่ม textarea ทางเลือก + payload message · VersionListItem.message + แสดงใน history (desktop ใต้ v{N} truncate + tooltip, mobile เต็มบรรทัด)
  • 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 ผ่าน


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

  • Backend: TemplateField.Editor (string?, varchar 16 — null = default ของ Type) + EditorsByType/DefaultEditor + AppDbContext config + migration AddTemplateFieldEditor (nullable column เดี่ยว ไม่ backfill)
  • Backend: TemplateFieldDefinition.Editor (ทางเลือก — ไม่ส่ง/ว่าง = default) + validate ตาม type (ไม่รู้จัก/ไม่เข้ากับ type → 400) + normalize-on-write (ค่า = default → เก็บ null) + TemplateFieldDto.Editor คืนค่า resolved เสมอ
  • Web: util fieldEditor.ts (EDITORS_BY_TYPE/defaultEditor/editorOptions/resolveEditor/dateInputValue) + FieldEditor type ใน api.ts
  • Web: หน้าแก้ field definitions เพิ่ม editor select (แสดงเฉพาะ type text ที่มีทางเลือก >1, number/date แสดง —) + reset editor เป็น default เมื่อเปลี่ยน type + payload editor
  • Web: ฟอร์มสร้างเอกสาร render ตาม resolveEditor (textarea/number/date/textbox — preserve number/date control เดิม) + prefill re-generate ของ field date ผ่าน dateInputValue (input[type=date] รับเฉพาะ yyyy-MM-dd)
  • 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 คงเดิม)


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 (เต็มรูปแบบ)
6.4 (ทีหลัง, v4) FR-12 AC-15
7 — คุ้มกัน regression ทุก AC (รวม AC-13, 14)
8 — ทบทวน AC-1 ถึง AC-14 ทั้งหมด
9 (ทีหลัง, v5) FR-13 AC-16
10 (ทีหลัง, v6) FR-14 AC-17

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
  • AC-15: (v4) หน้ารายการเอกสารของ template + หน้า detail (ค่าทุก field + version history ขยายเห็นค่าต่อ version + ดาวน์โหลด) + re-generate ได้เอกสารใหม่ v1 โดยเอกสารต้นทางไม่มี version ใหม่ — รายละเอียดตาม FR-12
  • AC-16: (v5) พิมพ์คำอธิบายตอนสร้าง version ได้ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า · เว้นว่างได้ — รายละเอียดตาม FR-13
  • AC-17: (v6) admin เลือก editor ของ field type text เป็น textarea ได้ → ฟอร์มสร้างเอกสารแสดงเป็น textarea · number/date ใช้ control ตาม type · editor ไม่ถูกต้อง → 400 · ไม่ส่ง → default ของ type · ค่า field และการ generate ไม่เปลี่ยน — รายละเอียดตาม FR-14

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>
cd .. && dotnet test  # integration tests (60 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที)

# add-in (https://localhost:3000)
cd addin
npm install
npm run build         # webpack build
npm start             # dev server — sideload ผ่าน manifest นี้
npm test              # jest (50 ตัว)
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 (46 ตัว)

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; ข้อความ generate=v1 ใน phase เดิมอ่านเป็น v0 ตาม spec v7

  • Spec v7 + glossary + ADR
  • Backend schema/API/storage + generate v0
  • Add-in PDF capture/warning/history
  • Web เปิด Word/PDF พร้อม fallback
  • Tests/build + QA reviewer PASS (2026-09-08; backend 68 / add-in 63 / web 55)
  • ตรวจ Word จริง — ผ่านบน Word for Mac (2026-09-08) รวม fidelity และ Track Changes (ไม่มี markup ปน); Word for Windows / Word on the web ค้างตามรายการเดิม

Phase 12: ไฟล์ร่าง (draft) — FR-16 (เพิ่มทีหลัง, v8)

Phase FR AC
12 (ทีหลัง, v8) FR-16 (+ขยาย FR-5) AC-19

รายละเอียดเต็ม: fr16-draft-generation-plan.md — decisions จาก grill session (stateless, stamp TemplateId ตัวเดียว, ผูก template ผ่าน form field templateId ตอน Start Versioning, deactivated ผูกได้, คง flow v0, ศัพท์ "ไฟล์ร่าง")

  • Spec v8 + glossary + ADR 0002
  • Backend: POST /api/templates/{id}/generate-draft (ตอน v8 คืนไฟล์ตรงๆ — ปรับใน v9) + DocxGenerationService.GenerateDraft + แยก TemplateFieldValidation + form field templateId ของ upload
  • Add-in: อ่าน TemplateId + ส่ง templateId เฉพาะ Start Versioning + hint ไฟล์ร่างใน task pane
  • Web: ปุ่ม "สร้างไฟล์ร่าง"
  • Tests/build (backend 82 / add-in 66 / web 59 + build ทั้ง add-in/web)
  • QA reviewer PASS (2 rounds, 2026-09-10)
  • v9 (feedback จากการใช้จริง): เปิดใน Word ทันที — เก็บ object ใต้ drafts/ + lifecycle รายวัน + คืน presigned URL + useVersionOpen.openDocxUrl · แก้ตัดคำไทย — ค่าเขียนที่ run ข้อความ placeholder จริง (complex script) + SDT เปล่าสืบทอด sdtPr rPr (backend 84 / web 56) + QA reviewer PASS (2 rounds, 2026-09-10)
  • ตรวจ Word จริง (manual): เว็บสร้างไฟล์ร่าง → Word เปิดทันที + ตัดคำไทยถูก → untracked + hint → Start Versioning → v1 ผูก template + fields sync · รวมในรายการค้างเดิมของ Word for Windows / Word on the web